"use strict";

describe("Zotero.Sync.Runner", function () {
	var apiKey = Zotero.Utilities.randomString(24);
	var baseURL = "http://local.zotero/";
	var userLibraryID, runner, caller, server, stub, spy;
	
	var responses = {
		keyInfo: {
			fullAccess: {
				method: "GET",
				url: "keys/current",
				status: 200,
				json: {
					key: apiKey,
					userID: 1,
					username: "Username",
					access: {
						user: {
							library: true,
							files: true,
							notes: true,
							write: true
						},
						groups: {
							all: {
								library: true,
								write: true
							}
						}
					}
				}
			}
		},
		userGroups: {
			groupVersions: {
				method: "GET",
				url: "users/1/groups?format=versions",
				json: {
					"1623562": 10,
					"2694172": 11
				}
			},
			groupVersionsEmpty: {
				method: "GET",
				url: "users/1/groups?format=versions",
				json: {}
			},
			groupVersionsOnlyMemberGroup: {
				method: "GET",
				url: "users/1/groups?format=versions",
				json: {
					"2694172": 11
				}
			}
		},
		groups: {
			ownerGroup: {
				method: "GET",
				url: "groups/1623562",
				json: {
					id: 1623562,
					version: 10,
					data: {
						id: 1623562,
						version: 10,
						name: "Group Name",
						description: "<p>Test group</p>",
						owner: 1,
						type: "Private",
						libraryEditing: "members",
						libraryReading: "all",
						fileEditing: "members",
						admins: [],
						members: []
					}
				}
			},
			memberGroup: {
				method: "GET",
				url: "groups/2694172",
				json: {
					id: 2694172,
					version: 11,
					data: {
						id: 2694172,
						version: 11,
						name: "Group Name 2",
						description: "<p>Test group</p>",
						owner: 123456,
						type: "Private",
						libraryEditing: "admins",
						libraryReading: "all",
						fileEditing: "admins",
						admins: [],
						members: [1]
					}
				}
			}
		}
	};
	
	//
	// Helper functions
	//
	function setResponse(response) {
		setHTTPResponse(server, baseURL, response, responses);
	}
	
	function setDefaultResponses(options = {}) {
		var target = options.target || 'users/1';
		var headers = {
			"Last-Modified-Version": options.libraryVersion || 5
		};
		var lastLibraryVersion = options.lastLibraryVersion || 4;
		setResponse({
			method: "GET",
			url: `${target}/settings?since=${lastLibraryVersion}`,
			status: 200,
			headers,
			json: {}
		});
		setResponse({
			method: "GET",
			url: `${target}/collections?format=versions&since=${lastLibraryVersion}`,
			status: 200,
			headers,
			json: {}
		});
		setResponse({
			method: "GET",
			url: `${target}/searches?format=versions&since=${lastLibraryVersion}`,
			status: 200,
			headers,
			json: {}
		});
		setResponse({
			method: "GET",
			url: `${target}/items/top?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
			status: 200,
			headers,
			json: {}
		});
		setResponse({
			method: "GET",
			url: `${target}/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
			status: 200,
			headers,
			json: {}
		});
		setResponse({
			method: "GET",
			url: `${target}/deleted?since=${lastLibraryVersion}`,
			status: 200,
			headers,
			json: {}
		});
		setResponse({
			method: "GET",
			url: `${target}/fulltext?format=versions`,
			status: 200,
			headers,
			json: {}
		});
	}
	
	
	//
	// Tests
	//
	beforeEach(function* () {
		yield resetData();
		
		userLibraryID = Zotero.Libraries.userLibraryID;
		
		Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
		server = sinon.fakeServer.create();
		server.autoRespond = true;
		
		runner = new Zotero.Sync.Runner_Module({ baseURL, apiKey });
		
		const { ConcurrentCaller } = ChromeUtils.importESModule("resource://zotero/concurrentCaller.mjs");
		caller = new ConcurrentCaller(1);
		caller.setLogger(msg => Zotero.debug(msg));
		caller.stopOnError = true;
		caller.onError = function (e) {
			Zotero.logError(e);
			if (options.onError) {
				options.onError(e);
			}
			if (e.fatal) {
				caller.stop();
				throw e;
			}
		};
		
		yield Zotero.Users.setCurrentUserID(1);
		yield Zotero.Users.setCurrentUsername("A");
	})
	afterEach(function () {
		if (stub) stub.restore();
		if (spy) spy.restore();
	})
	after(function () {
		Zotero.HTTP.mock = null;
	})
	
	describe("#checkAccess()", function () {
		it("should check key access", async function () {
			setResponse('keyInfo.fullAccess');
			var json = await runner.checkAccess(runner.getAPIClient({ apiKey }));
			var compare = {};
			Object.assign(compare, responses.keyInfo.fullAccess.json);
			delete compare.key;
			assert.deepEqual(json, compare);
		})
	})
	
	describe("#checkLibraries()", function () {
		beforeEach(function* () {
			Zotero.Prefs.clear('sync.librariesToSkip');
		});
		
		afterEach(function* () {
			Zotero.Prefs.clear('sync.librariesToSkip');
			
			var group = Zotero.Groups.get(responses.groups.ownerGroup.json.id);
			if (group) {
				yield group.eraseTx();
			}
			group = Zotero.Groups.get(responses.groups.memberGroup.json.id);
			if (group) {
				yield group.eraseTx();
			}
		})
		
		it("should check library access and versions without library list", async function () {
			// Create group with same id and version as groups response
			var groupData = responses.groups.ownerGroup;
			var group1 = await createGroup({
				id: groupData.json.id,
				version: groupData.json.version
			});
			groupData = responses.groups.memberGroup;
			var group2 = await createGroup({
				id: groupData.json.id,
				version: groupData.json.version
			});
			
			setResponse('userGroups.groupVersions');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.lengthOf(libraries, 3);
			assert.sameMembers(
				libraries,
				[userLibraryID, group1.libraryID, group2.libraryID]
			);
		})
		
		it("should check library access and versions with library list", async function () {
			// Create groups with same id and version as groups response
			var groupData = responses.groups.ownerGroup;
			var group1 = await createGroup({
				id: groupData.json.id,
				version: groupData.json.version
			});
			groupData = responses.groups.memberGroup;
			var group2 = await createGroup({
				id: groupData.json.id,
				version: groupData.json.version
			});
			
			setResponse('userGroups.groupVersions');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json,
				[userLibraryID]
			);
			assert.lengthOf(libraries, 1);
			assert.sameMembers(libraries, [userLibraryID]);
			
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json,
				[userLibraryID]
			);
			assert.lengthOf(libraries, 1);
			assert.sameMembers(libraries, [userLibraryID]);
			
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json,
				[group1.libraryID]
			);
			assert.lengthOf(libraries, 1);
			assert.sameMembers(libraries, [group1.libraryID]);
		})
		
		it("should filter out nonexistent skipped libraries if library list not provided", async function () {
			var unskippedGroupID = responses.groups.ownerGroup.json.id;
			var skippedGroupID = responses.groups.memberGroup.json.id;
			Zotero.Prefs.set('sync.librariesToSkip', `["L4", "G${skippedGroupID}"]`);
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json
			);
			
			var group = Zotero.Groups.get(unskippedGroupID);
			assert.lengthOf(libraries, 2);
			assert.sameMembers(libraries, [userLibraryID, group.libraryID]);
			
			assert.isFalse(Zotero.Groups.get(skippedGroupID));
		});
		
		it("should filter out existing skipped libraries if library list not provided", async function () {
			var unskippedGroupID = responses.groups.ownerGroup.json.id;
			var skippedGroupID = responses.groups.memberGroup.json.id;
			Zotero.Prefs.set('sync.librariesToSkip', `["L4", "G${skippedGroupID}"]`);
			
			var skippedGroup = await createGroup({
				id: skippedGroupID,
				version: responses.groups.memberGroup.json.version - 1
			});
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json
			);
			
			var group = Zotero.Groups.get(unskippedGroupID);
			assert.lengthOf(libraries, 2);
			assert.sameMembers(libraries, [userLibraryID, group.libraryID]);
			
			assert.equal(skippedGroup.version, responses.groups.memberGroup.json.version - 1);
		});
		
		it("should filter out remotely missing archived libraries if library list not provided", async function () {
			var ownerGroupID = responses.groups.ownerGroup.json.id;
			var archivedGroupID = 162512451; // nonexistent group id
			
			var ownerGroup = await createGroup({
				id: ownerGroupID,
				version: responses.groups.ownerGroup.json.version
			});
			var archivedGroup = await createGroup({
				id: archivedGroupID,
				editable: false,
				archived: true
			});
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.memberGroup');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json
			);
			
			assert.lengthOf(libraries, 3);
			assert.sameMembers(
				libraries,
				[
					userLibraryID,
					ownerGroup.libraryID,
					// Nonexistent group should've been created
					Zotero.Groups.getLibraryIDFromGroupID(responses.groups.memberGroup.json.id)
				]
			);
		});
		
		it("should unarchive library if available remotely", async function () {
			var syncedGroupID = responses.groups.ownerGroup.json.id;
			var archivedGroupID = responses.groups.memberGroup.json.id;
			
			var syncedGroup = await createGroup({
				id: syncedGroupID,
				version: responses.groups.ownerGroup.json.version
			});
			var archivedGroup = await createGroup({
				id: archivedGroupID,
				version: responses.groups.memberGroup.json.version - 1,
				editable: false,
				archived: true
			});
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json
			);
			
			assert.lengthOf(libraries, 3);
			assert.sameMembers(
				libraries,
				[userLibraryID, syncedGroup.libraryID, archivedGroup.libraryID]
			);
			assert.isFalse(archivedGroup.archived);
		});
		
		it("shouldn't filter out skipped libraries if library list is provided", async function () {
			var groupData = responses.groups.memberGroup;
			var group = await createGroup({
				id: groupData.json.id,
				version: groupData.json.version
			});
			
			Zotero.Prefs.set('sync.librariesToSkip', `["L4", "G${group.id}"]`);
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json,
				[userLibraryID, group.libraryID]
			);
			
			assert.lengthOf(libraries, 2);
			assert.sameMembers(libraries, [userLibraryID, group.libraryID]);
		});
		
		it("should update outdated group metadata", async function () {
			// Create groups with same id as groups response but earlier versions
			var groupData1 = responses.groups.ownerGroup;
			var group1 = await createGroup({
				id: groupData1.json.id,
				version: groupData1.json.version - 1,
				editable: false
			});
			var groupData2 = responses.groups.memberGroup;
			var group2 = await createGroup({
				id: groupData2.json.id,
				version: groupData2.json.version - 1,
				editable: true
			});
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			// Simulate acceptance of library reset for group 2 editable change
			var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess")
				.returns(Promise.resolve(true));
			
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			
			assert.ok(stub.calledTwice);
			stub.restore();
			assert.lengthOf(libraries, 3);
			assert.sameMembers(
				libraries,
				[userLibraryID, group1.libraryID, group2.libraryID]
			);
			
			assert.equal(group1.name, groupData1.json.data.name);
			assert.equal(group1.version, groupData1.json.version);
			assert.isTrue(group1.editable);
			assert.equal(group2.name, groupData2.json.data.name);
			assert.equal(group2.version, groupData2.json.version);
			assert.isFalse(group2.editable);
		})
		
		it("should update outdated group metadata for group created with classic sync", async function () {
			var groupData1 = responses.groups.ownerGroup;
			var group1 = await createGroup({
				id: groupData1.json.id,
				version: 0,
				editable: false
			});
			var groupData2 = responses.groups.memberGroup;
			var group2 = await createGroup({
				id: groupData2.json.id,
				version: 0,
				editable: true
			});
			
			await Zotero.DB.queryAsync(
				"UPDATE groups SET version=0 WHERE groupID IN (?, ?)", [group1.id, group2.id]
			);
			await Zotero.Libraries.init();
			group1 = Zotero.Groups.get(group1.id);
			group2 = Zotero.Groups.get(group2.id);
			
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			// Simulate acceptance of library reset for group 2 editable change
			var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess")
				.returns(Promise.resolve(true));
			
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }),
				false,
				responses.keyInfo.fullAccess.json,
				[group1.libraryID, group2.libraryID]
			);
			
			assert.ok(stub.calledTwice);
			stub.restore();
			assert.lengthOf(libraries, 2);
			assert.sameMembers(libraries, [group1.libraryID, group2.libraryID]);
			
			assert.equal(group1.name, groupData1.json.data.name);
			assert.equal(group1.version, groupData1.json.version);
			assert.isTrue(group1.editable);
			assert.equal(group2.name, groupData2.json.data.name);
			assert.equal(group2.version, groupData2.json.version);
			assert.isFalse(group2.editable);
		})
		
		it("should create locally missing groups", async function () {
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.lengthOf(libraries, 3);
			var groupData1 = responses.groups.ownerGroup;
			var group1 = Zotero.Groups.get(groupData1.json.id);
			var groupData2 = responses.groups.memberGroup;
			var group2 = Zotero.Groups.get(groupData2.json.id);
			assert.ok(group1);
			assert.ok(group2);
			assert.sameMembers(
				libraries,
				[userLibraryID, group1.libraryID, group2.libraryID]
			);
			assert.equal(group1.name, groupData1.json.data.name);
			assert.isTrue(group1.editable);
			assert.equal(group2.name, groupData2.json.data.name);
			assert.isFalse(group2.editable);
		})
		
		it("should delete remotely missing groups", async function () {
			var groupData1 = responses.groups.ownerGroup;
			var group1 = await createGroup({ id: groupData1.json.id, version: groupData1.json.version });
			var groupData2 = responses.groups.memberGroup;
			var group2 = await createGroup({ id: groupData2.json.id, version: groupData2.json.version });
			
			setResponse('userGroups.groupVersionsOnlyMemberGroup');
			waitForDialog(function (dialog) {
				var text = dialog.document.documentElement.textContent;
				assert.include(text, group1.name);
			});
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.lengthOf(libraries, 2);
			assert.sameMembers(libraries, [userLibraryID, group2.libraryID]);
			assert.isFalse(Zotero.Groups.exists(groupData1.json.id));
			assert.isTrue(Zotero.Groups.exists(groupData2.json.id));
		})
		
		it("should keep remotely missing groups", async function () {
			var group1 = await createGroup({ editable: true, filesEditable: true });
			var group2 = await createGroup({ editable: true, filesEditable: true });
			
			setResponse('userGroups.groupVersionsEmpty');
			var called = 0;
			var otherGroup;
			waitForDialog(function (dialog) {
				called++;
				var text = dialog.document.documentElement.textContent;
				if (text.includes(group1.name)) {
					otherGroup = group2;
				}
				else if (text.includes(group2.name)) {
					otherGroup = group1;
				}
				else {
					throw new Error("Dialog text does not include either group name");
				}
				
				waitForDialog(function (dialog) {
					called++;
					var text = dialog.document.documentElement.textContent;
					assert.include(text, otherGroup.name);
				}, "extra1");
			}, "extra1");
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.equal(called, 2);
			assert.lengthOf(libraries, 1);
			assert.sameMembers(libraries, [userLibraryID]);
			// Groups should still exist but be read-only and archived
			[group1, group2].forEach((group) => {
				assert.isTrue(Zotero.Groups.exists(group.id));
				assert.isTrue(group.archived);
				assert.isFalse(group.editable);
				assert.isFalse(group.filesEditable);
			});
		})
		
		it("should cancel sync with remotely missing groups", async function () {
			var groupData = responses.groups.ownerGroup;
			var group = await createGroup({ id: groupData.json.id, version: groupData.json.version });
			
			setResponse('userGroups.groupVersionsEmpty');
			waitForDialog(function (dialog) {
				var text = dialog.document.documentElement.textContent;
				assert.include(text, group.name);
			}, "cancel");
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.lengthOf(libraries, 0);
			assert.isTrue(Zotero.Groups.exists(groupData.json.id));
		})
		
		it("should prompt to revert local changes on loss of library write access", async function () {
			var group = await createGroup({
				version: 1,
				libraryVersion: 2
			});
			var libraryID = group.libraryID;
			
			setResponse({
				method: "GET",
				url: "users/1/groups?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 3
				},
				json: {
					[group.id]: 3
				}
			});
			setResponse({
				method: "GET",
				url: "groups/" + group.id,
				status: 200,
				headers: {
					"Last-Modified-Version": 3
				},
				json: {
					id: group.id,
					version: 2,
					data: {
						// Make group read-only
						id: group.id,
						version: 2,
						name: group.name,
						description: group.description,
						owner: 2,
						type: "Private",
						libraryEditing: "admins",
						libraryReading: "all",
						fileEditing: "admins",
						admins: [],
						members: [1]
					}
				}
			});
			
			// First, test cancelling
			var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess")
				.returns(Promise.resolve(false));
			var libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.notInclude(libraries, group.libraryID);
			assert.isTrue(stub.calledOnce);
			assert.isTrue(group.editable);
			stub.reset();
			
			// Next, reset
			stub.returns(Promise.resolve(true));
			libraries = await runner.checkLibraries(
				runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json
			);
			assert.include(libraries, group.libraryID);
			assert.isTrue(stub.calledOnce);
			assert.isFalse(group.editable);
			
			stub.restore();
		});
	})

	describe("#sync()", function () {
		it("should perform a sync across all libraries and update library versions", async function () {
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			// My Library
			setResponse({
				method: "GET",
				url: "users/1/settings",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "users/1/collections?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "users/1/searches?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "users/1/items/top?format=versions&includeTrashed=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "users/1/items?format=versions&includeTrashed=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "users/1/deleted?since=0",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: []
			});
			// Group library 1
			setResponse({
				method: "GET",
				url: "groups/1623562/settings",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/1623562/collections?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/1623562/searches?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/1623562/items/top?format=versions&includeTrashed=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/1623562/items?format=versions&includeTrashed=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/1623562/deleted?since=0",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: []
			});
			// Group library 2
			setResponse({
				method: "GET",
				url: "groups/2694172/settings",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/2694172/collections?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/2694172/searches?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/2694172/items/top?format=versions&includeTrashed=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/2694172/items?format=versions&includeTrashed=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: []
			});
			setResponse({
				method: "GET",
				url: "groups/2694172/deleted?since=0",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: []
			});
			// Full-text syncing
			setResponse({
				method: "GET",
				url: "users/1/fulltext?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 5
				},
				json: {}
			});
			setResponse({
				method: "GET",
				url: "groups/1623562/fulltext?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 15
				},
				json: {}
			});
			setResponse({
				method: "GET",
				url: "groups/2694172/fulltext?format=versions",
				status: 200,
				headers: {
					"Last-Modified-Version": 20
				},
				json: {}
			});
			
			var startTime = new Date().getTime();
			
			await runner.sync({
				onError: e => { throw e },
			});
			
			// Check local library versions
			assert.equal(
				Zotero.Libraries.getVersion(userLibraryID),
				5
			);
			assert.equal(
				Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(1623562)),
				15
			);
			assert.equal(
				Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(2694172)),
				20
			);
			
			// Last sync time should be within the last few seconds
			var lastSyncTime = Zotero.Sync.Data.Local.getLastSyncTime();
			assert.isAbove(lastSyncTime.getTime(), startTime);
			assert.isBelow(lastSyncTime.getTime(), new Date().getTime());
		})
		
		
		it("should handle user-initiated cancellation", async function () {
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			
			var stub = sinon.stub(Zotero.Sync.Data.Engine.prototype, "start");
			
			stub.onCall(0).returns(Promise.resolve());
			var e = new Zotero.Sync.UserCancelledException();
			e.handledRejection = true;
			stub.onCall(1).returns(Promise.reject(e));
			// Shouldn't be reached
			stub.onCall(2).throws();
			
			await runner.sync({
				onError: e => { throw e },
			});
			
			stub.restore();
		});
		
		
		it("should handle user-initiated cancellation for current library", async function () {
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			
			var stub = sinon.stub(Zotero.Sync.Data.Engine.prototype, "start");
			
			stub.returns(Promise.resolve());
			var e = new Zotero.Sync.UserCancelledException(true);
			e.handledRejection = true;
			stub.onCall(1).returns(Promise.reject(e));
			
			await runner.sync({
				onError: e => { throw e },
			});
			
			assert.equal(stub.callCount, 3);
			stub.restore();
		});
	})
	
	
	describe("#createAPIKeyFromCredentials()", function () {
		var data = {
			name: "Automatic Zotero Client Key",
			username: "Username",
			access: {
				user: {
					library: true,
					files: true,
					notes: true,
					write: true
				},
				groups: {
					all: {
						library: true,
						write: true
					}
				}
			}
		};
		var correctPostData = Object.assign({password: 'correctPassword'}, data);
		var incorrectPostData = Object.assign({password: 'incorrectPassword'}, data);
		var responseData = Object.assign({userID: 1, key: apiKey}, data);

		it("should return json with key when credentials valid", async function () {
			server.respond(function (req) {
				if (req.method == "POST") {
					var json = JSON.parse(req.requestBody);
					assert.deepEqual(json, correctPostData);
					req.respond(201, {}, JSON.stringify(responseData));
				}
			});

			var json = await runner.createAPIKeyFromCredentials('Username', 'correctPassword');
			assert.equal(json.key, apiKey);
		});

		it("should return false when credentials invalid", async function () {
			server.respond(function (req) {
				if (req.method == "POST") {
					var json = JSON.parse(req.requestBody);
					assert.deepEqual(json, incorrectPostData);
					req.respond(403);
				}
			});

			var key = await runner.createAPIKeyFromCredentials('Username', 'incorrectPassword');
			assert.isFalse(key);
		});
	});

	describe("#deleteAPIKey()", function () {
		it("should send DELETE request with correct key", async function () {
			Zotero.Sync.Data.Local.setAPIKey(apiKey);

			server.respond(function (req) {
				if (req.method == "DELETE") {
					assert.propertyVal(req.requestHeaders, 'Zotero-API-Key', apiKey);
					assert.equal(req.url, baseURL + "keys/current");
				}
				req.respond(204);
			});

			await runner.deleteAPIKey();
		});
	});
	
	
	describe("Error Handling", function () {
		var win;
		
		afterEach(function () {
			if (win) {
				win.close();
			}
		});
		
		it("should show the sync error icon on error", async function () {
			let library = Zotero.Libraries.userLibrary;
			library.libraryVersion = 1;
			await library.save();
			
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersionsEmpty');
			
			// No other responses, so settings response will be a 404
			
			spy = sinon.spy(runner, "updateIcons");
			await runner.sync();
			assert.isTrue(spy.calledTwice);
			assert.isArray(spy.args[1][0]);
			assert.lengthOf(spy.args[1][0], 1);
			// Not an instance of Error for some reason
			var error = spy.args[1][0][0];
			assert.equal(Object.getPrototypeOf(error).constructor.name, "Error");
		});
		
		
		it("should show a custom button in the error panel", async function () {
			win = await loadZoteroPane();
			var libraryID = Zotero.Libraries.userLibraryID;
			
			setResponse({
				method: "GET",
				url: "keys/current",
				status: 403,
				headers: {},
				text: "Invalid Key"
			});
			await runner.sync({
				background: true
			});
			
			var doc = win.document;
			var errorIcon = doc.getElementById('zotero-tb-sync-error');
			assert.isFalse(errorIcon.hidden);
			errorIcon.click();
			var panel = win.document.getElementById('zotero-sync-error-panel');
			var buttons = panel.getElementsByTagName('button');
			assert.lengthOf(buttons, 1);
			assert.equal(buttons[0].label, Zotero.ftl.formatValueSync('account-log-in'));
		});
		
		
		it("should show a button in error panel to select a too-long note", async function () {
			win = await loadZoteroPane();
			var doc = win.document;
			
			var text = "".padStart(256, "a");
			var item = await createDataObject('item', { itemType: 'note', note: text });
			
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersions');
			setResponse('groups.ownerGroup');
			setResponse('groups.memberGroup');
			
			server.respond(function (req) {
				if (req.method == "POST" && req.url == baseURL + "users/1/items") {
					req.respond(
						200,
						{
							"Last-Modified-Version": 5
						},
						JSON.stringify({
							successful: {},
							success: {},
							unchanged: {},
							failed: {
								"0": {
									code: 413,
									message: `Note ${Zotero.Utilities.ellipsize(text, 100)} too long`
								}
							}
						})
					);
				}
			});
			
			await runner.sync({ libraries: [Zotero.Libraries.userLibraryID] });
			
			var errorIcon = doc.getElementById('zotero-tb-sync-error');
			assert.isFalse(errorIcon.hidden);
			errorIcon.click();
			var panel = win.document.getElementById('zotero-sync-error-panel');
			assert.include(panel.innerHTML, text.substr(0, 10));
			var buttons = panel.getElementsByTagName('button');
			assert.lengthOf(buttons, 1);
			assert.include(buttons[0].label, Zotero.getString('pane.items.showItemInLibrary'));
		});
		
		
		it("should show an error for invalid My Library data", async function () {
			let library = Zotero.Libraries.userLibrary;
			library.libraryVersion = 1;
			await library.save();
			
			var collection = await createDataObject('collection', { synced: true });
			var json = collection.toResponseJSON();
			json.version = json.data.version = 2;
			json.data.INVALID = true;
			
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersionsEmpty');
			setDefaultResponses({
				lastLibraryVersion: 1,
				libraryVersion: 2
			});
			setResponse({
				method: "GET",
				url: "users/1/collections?format=versions&since=1",
				status: 200,
				headers: {
					"Last-Modified-Version": 2
				},
				json: {
					[json.key]: 2
				}
			});
			setResponse({
				method: "GET",
				url: `users/1/collections?collectionKey=${json.key}`,
				status: 200,
				headers: {
					"Last-Modified-Version": 2
				},
				json: [json]
			});
			
			spy = sinon.spy(runner, "updateIcons");
			await runner.sync();
			assert.isTrue(spy.calledTwice);
			assert.isArray(spy.args[1][0]);
			assert.lengthOf(spy.args[1][0], 1);
			// Not an instance of Error for some reason
			var error = spy.args[1][0][0];
			assert.equal(Object.getPrototypeOf(error).constructor.name, "Error");
			assert.match(error.message, /^Some data in My Library/);
		});
		
		
		it("should show a warning in the sync button tooltip for invalid group data", async function () {
			win = await loadZoteroPane();
			var doc = win.document;
			
			// Create group with same id and version as groups response
			var groupData = responses.groups.memberGroup;
			var group = await createGroup({
				id: groupData.json.id,
				version: groupData.json.version
			});
			group.libraryVersion = 1;
			await group.save();
			
			var collection = await createDataObject('collection', { synced: true });
			var json = collection.toResponseJSON();
			json.version = json.data.version = 2;
			json.data.INVALID = true;
			
			var target = 'groups/' + group.id;
			setResponse('keyInfo.fullAccess');
			setResponse('userGroups.groupVersionsOnlyMemberGroup');
			setResponse('groups.memberGroup');
			setDefaultResponses({
				target,
				lastLibraryVersion: 1,
				libraryVersion: 2
			});
			setResponse({
				method: "GET",
				url: target + '/collections?format=versions&since=1',
				status: 200,
				headers: {
					"Last-Modified-Version": 2
				},
				json: {
					[json.key]: 2
				}
			});
			setResponse({
				method: "GET",
				url: target + `/collections?collectionKey=${json.key}`,
				status: 200,
				headers: {
					"Last-Modified-Version": 2
				},
				json: [json]
			});
			
			await runner.sync({ libraries: [group.libraryID] });
			
			assert.isTrue(doc.getElementById('zotero-tb-sync-error').hidden);
			
			// Fake what happens on button mouseover
			var tooltip = doc.getElementById('zotero-tb-sync-tooltip');
			runner.registerSyncStatus(tooltip);
			
			var html = doc.getElementById('zotero-tb-sync-tooltip').innerHTML;
			assert.match(html, /Some data in .+\. Other data will continue to sync\./);
			
			runner.registerSyncStatus();
		});
		
		
		// TODO: Test multiple long tags and tags across libraries
		describe("Long Tag Fixer", function () {
			it("should split a tag", async function () {
				win = await loadZoteroPane();
				
				var item = await createDataObject('item');
				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";
				item.addTag(tag, 1);
				await item.saveTx();
				
				setResponse('keyInfo.fullAccess');
				setResponse('userGroups.groupVersions');
				setResponse('groups.ownerGroup');
				setResponse('groups.memberGroup');
				
				server.respond(function (req) {
					if (req.method == "POST" && req.url == baseURL + "users/1/items") {
						var json = JSON.parse(req.requestBody);
						if (json[0].tags.length == 1) {
							req.respond(
								200,
								{
									"Last-Modified-Version": 5
								},
								JSON.stringify({
									successful: {},
									success: {},
									unchanged: {},
									failed: {
										"0": {
											code: 413,
											message: "Tag 'title;feeling;matter;drum;treatment;caring;earthy;shrill;unit;obedient;hover…' is too long to sync",
											data: {
												tag
											}
										}
									}
								})
							);
						}
						else {
							let itemJSON = item.toResponseJSON();
							itemJSON.version = 6;
							itemJSON.data.version = 6;
							
							req.respond(
								200,
								{
									"Last-Modified-Version": 6
								},
								JSON.stringify({
									successful: {
										"0": itemJSON
									},
									success: {
										"0": json[0].key
									},
									unchanged: {},
									failed: {}
								})
							);
						}
					}
				});
				
				waitForDialog(null, 'accept', 'chrome://zotero/content/longTagFixer.xhtml');
				await runner.sync({ libraries: [Zotero.Libraries.userLibraryID] });
				
				assert.isFalse(Zotero.Tags.getID(tag));
				assert.isNumber(Zotero.Tags.getID('feeling'));
			});
			
			it("should delete a tag", async function () {
				win = await loadZoteroPane();
				
				var item = await createDataObject('item');
				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";
				item.addTag(tag, 1);
				await item.saveTx();
				
				setResponse('keyInfo.fullAccess');
				setResponse('userGroups.groupVersions');
				setResponse('groups.ownerGroup');
				setResponse('groups.memberGroup');
				
				server.respond(function (req) {
					if (req.method == "POST" && req.url == baseURL + "users/1/items") {
						var json = JSON.parse(req.requestBody);
						if (json[0].tags.length == 1) {
							req.respond(
								200,
								{
									"Last-Modified-Version": 5
								},
								JSON.stringify({
									successful: {},
									success: {},
									unchanged: {},
									failed: {
										"0": {
											code: 413,
											message: "Tag 'title;feeling;matter;drum;treatment;caring;earthy;shrill;unit;obedient;hover…' is too long to sync",
											data: {
												tag
											}
										}
									}
								})
							);
						}
						else {
							let itemJSON = item.toResponseJSON();
							itemJSON.version = 6;
							itemJSON.data.version = 6;
							
							req.respond(
								200,
								{
									"Last-Modified-Version": 6
								},
								JSON.stringify({
									successful: {
										"0": itemJSON
									},
									success: {
										"0": json[0].key
									},
									unchanged: {},
									failed: {}
								})
							);
						}
					}
				});
				
				waitForDialog(function (window) {
					window.Zotero_Long_Tag_Fixer.switchMode(2);
				}, 'accept', 'chrome://zotero/content/longTagFixer.xhtml');
				await runner.sync({ libraries: [Zotero.Libraries.userLibraryID] });
				
				assert.isFalse(Zotero.Tags.getID(tag));
				assert.isFalse(Zotero.Tags.getID('feeling'));
			});
		});
	});
})
