API syncing megacommit

There's a lot more to do, and this isn't ready for actual usage, but the
basic functionality is mostly in place and has decent test coverage. It
can successfully upgrade a library last used with classic syncing and
pull down changes via the API. Uploading mostly works but is currently
disabled for safety until it has better test coverage.

Downloaded JSON is first saved to a cache table, which is then used to
populate other tables and later for generating PATCH requests and
automatically resolving conflicts (since it shows what was changed
locally and what was changed remotely). Objects with unmet dependencies
or unknown fields are skipped for now but don't block the rest of the
sync.

Some of the bigger remaining to-dos:

- Tests for uploading
- Re-do the preferences to get an API key
- File sync integration
- Full-text syncing integration
- Manual conflict resolution (though this already includes much smarter
  conflict handling that automatically resolves many conflicts)
This commit is contained in:
Dan Stillman 2015-07-20 17:27:55 -04:00
parent b4a8083f2f
commit 984789d304
27 changed files with 6058 additions and 1149 deletions

View file

@ -627,3 +627,40 @@ function importFileAttachment(filename) {
filename.split('/').forEach((part) => testfile.append(part));
return Zotero.Attachments.importFromFile({file: testfile});
}
/**
* Sets the fake XHR server to response to a given response
*
* @param {Object} server - Sinon FakeXMLHttpRequest server
* @param {Object|String} response - Dot-separated path to predefined response in responses
* object (e.g., keyInfo.fullAccess) or a JSON object
* that defines the response
* @param {Object} responses - Predefined responses
*/
function setHTTPResponse(server, baseURL, response, responses) {
if (typeof response == 'string') {
let [topic, key] = response.split('.');
if (!responses[topic]) {
throw new Error("Invalid topic");
}
if (!responses[topic][key]) {
throw new Error("Invalid response key");
}
response = responses[topic][key];
}
var responseArray = [response.status || 200, {}, ""];
if (response.json) {
responseArray[1]["Content-Type"] = "application/json";
responseArray[2] = JSON.stringify(response.json);
}
else {
responseArray[1]["Content-Type"] = "text/plain";
responseArray[2] = response.text || "";
}
for (let i in response.headers) {
responseArray[1][i] = response.headers[i];
}
server.respondWith(response.method, baseURL + response.url, responseArray);
}

View file

@ -0,0 +1,754 @@
"use strict";
describe("Zotero.Sync.Data.Engine", function () {
var apiKey = Zotero.Utilities.randomString(24);
var baseURL = "http://local.zotero/";
var engine, server, client, caller, stub, spy;
var responses = {};
var setup = Zotero.Promise.coroutine(function* (options) {
options = options || {};
server = sinon.fakeServer.create();
server.autoRespond = true;
Components.utils.import("resource://zotero/concurrent-caller.js");
var 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;
}
};
var client = new Zotero.Sync.APIClient({
baseURL: baseURL,
apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
apiKey: apiKey,
concurrentCaller: caller,
background: options.background || true
});
var engine = new Zotero.Sync.Data.Engine({
apiClient: client,
libraryID: options.libraryID || Zotero.Libraries.userLibraryID
});
return { engine, client, caller };
});
function setResponse(response) {
setHTTPResponse(server, baseURL, response, responses);
}
function makeCollectionJSON(options) {
return {
key: options.key,
version: options.version,
data: {
key: options.key,
version: options.version,
name: options.name
}
};
}
function makeSearchJSON(options) {
return {
key: options.key,
version: options.version,
data: {
key: options.key,
version: options.version,
name: options.name,
conditions: options.conditions ? options.conditions : [
{
condition: 'title',
operator: 'contains',
value: 'test'
}
]
}
};
}
function makeItemJSON(options) {
var json = {
key: options.key,
version: options.version,
data: {
key: options.key,
version: options.version,
itemType: options.itemType || 'book',
title: options.title || options.name
}
};
Object.assign(json.data, options);
delete json.data.name;
return json;
}
// Allow functions to be called programmatically
var makeJSONFunctions = {
collection: makeCollectionJSON,
search: makeSearchJSON,
item: makeItemJSON
};
//
// Tests
//
beforeEach(function* () {
this.timeout(60000);
yield resetDB({
skipBundledFiles: true
});
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("testuser");
})
after(function* () {
this.timeout(60000);
yield resetDB();
})
describe("Syncing", function () {
it("should perform a sync for a new library", function* () {
({ engine, client, caller } = yield setup());
server.respond(function (req) {
if (req.method == "POST" && req.url == baseURL + "users/1/items") {
let ifUnmodifiedSince = req.requestHeaders["If-Unmodified-Since-Version"];
if (ifUnmodifiedSince == 0) {
req.respond(412, {}, "Library has been modified since specified version");
return;
}
if (ifUnmodifiedSince == 3) {
let json = JSON.parse(req.requestBody);
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": 3
},
JSON.stringify({
success: {
"0": json[0].key,
"1": json[1].key
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
var headers = {
"Last-Modified-Version": 3
};
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {
tagColors: {
value: [
{
name: "A",
color: "#CC66CC"
}
],
version: 2
}
}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 1
}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 2
}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 3
}
});
setResponse({
method: "GET",
url: "users/1/collections?format=json&collectionKey=AAAAAAAA",
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/searches?format=json&searchKey=AAAAAAAA",
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "AAAAAAAA",
version: 2,
name: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/items?format=json&itemKey=AAAAAAAA&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "AAAAAAAA",
version: 3,
itemType: "book",
title: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: {}
});
yield engine.start();
var userLibraryID = Zotero.Libraries.userLibraryID;
// Check local library version
assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
// Make sure local objects exist
var setting = yield Zotero.SyncedSettings.get(userLibraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = yield Zotero.SyncedSettings.getMetadata(userLibraryID, "tagColors");
assert.equal(settingMetadata.version, 2);
assert.isTrue(settingMetadata.synced);
var obj = yield Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 1);
assert.isTrue(obj.synced);
obj = yield Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 2);
assert.isTrue(obj.synced);
obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.getField('title'), 'A');
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
})
it("should make only one request if in sync", function* () {
yield Zotero.Libraries.setVersion(Zotero.Libraries.userLibraryID, 5);
({ engine, client, caller } = yield setup());
server.respond(function (req) {
if (req.method == "GET" && req.url == baseURL + "users/1/settings?since=5") {
let since = req.requestHeaders["If-Modified-Since-Version"];
if (since == 5) {
req.respond(304);
return;
}
}
});
yield engine.start();
})
})
describe("#_startDownload()", function () {
it("shouldn't redownload objects already in the cache", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
//yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup());
var objects = {};
for (let type of Zotero.DataObjectUtilities.getTypes()) {
let obj = objects[type] = createUnsavedDataObject(type);
obj.version = 5;
obj.synced = true;
yield obj.saveTx({ skipSyncedUpdate: true });
yield Zotero.Sync.Data.Local.saveCacheObjects(
type,
userLibraryID,
[
{
key: obj.key,
version: obj.version,
data: (yield obj.toJSON())
}
]
);
}
var json;
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {}
});
json = {};
json[objects.collection.key] = 5;
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: headers,
json: json
});
json = {};
json[objects.search.key] = 5;
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: headers,
json: json
});
json = {};
json[objects.item.key] = 5;
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: json
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: {}
});
yield engine._startDownload();
})
it("should apply remote deletions", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup());
// Create objects and mark them as synced
yield Zotero.SyncedSettings.set(
userLibraryID, 'tagColors', [{name: 'A', color: '#CC66CC'}], 1, true
);
var collection = createUnsavedDataObject('collection');
collection.synced = true;
var collectionID = yield collection.saveTx({ skipSyncedUpdate: true });
var collectionKey = collection.key;
var search = createUnsavedDataObject('search');
search.synced = true;
var searchID = yield search.saveTx({ skipSyncedUpdate: true });
var searchKey = search.key;
var item = createUnsavedDataObject('item');
item.synced = true;
var itemID = yield item.saveTx({ skipSyncedUpdate: true });
var itemKey = item.key;
var headers = {
"Last-Modified-Version": 6
};
setResponse({
method: "GET",
url: "users/1/settings?since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&since=5&includeTrashed=1",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/deleted?since=5",
status: 200,
headers: headers,
json: {
settings: ['tagColors'],
collections: [collection.key],
searches: [search.key],
items: [item.key]
}
});
yield engine._startDownload();
// Make sure objects were deleted
assert.isFalse(yield Zotero.SyncedSettings.get(userLibraryID, 'tagColors'));
assert.isFalse(Zotero.Collections.exists(collectionID));
assert.isFalse(Zotero.Searches.exists(searchID));
assert.isFalse(Zotero.Items.exists(itemID));
// Make sure objects weren't added to sync delete log
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'setting', userLibraryID, 'tagColors'
));
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'collection', userLibraryID, collectionKey
));
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'search', userLibraryID, searchKey
));
assert.isFalse(yield Zotero.Sync.Data.Local._objectInDeleteLog(
'item', userLibraryID, itemKey
));
})
it("should ignore remote deletions for non-item objects if local objects changed", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = yield setup());
// Create objects marked as unsynced
yield Zotero.SyncedSettings.set(
userLibraryID, 'tagColors', [{name: 'A', color: '#CC66CC'}]
);
var collection = createUnsavedDataObject('collection');
var collectionID = yield collection.saveTx();
var collectionKey = collection.key;
var search = createUnsavedDataObject('search');
var searchID = yield search.saveTx();
var searchKey = search.key;
var headers = {
"Last-Modified-Version": 6
};
setResponse({
method: "GET",
url: "users/1/settings?since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions&since=5",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&since=5&includeTrashed=1",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/deleted?since=5",
status: 200,
headers: headers,
json: {
settings: ['tagColors'],
collections: [collection.key],
searches: [search.key],
items: []
}
});
yield engine._startDownload();
// Make sure objects weren't deleted
assert.ok(yield Zotero.SyncedSettings.get(userLibraryID, 'tagColors'));
assert.ok(Zotero.Collections.exists(collectionID));
assert.ok(Zotero.Searches.exists(searchID));
})
})
describe("#_upgradeCheck()", function () {
it("should upgrade a library last synced with the classic sync architecture", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
yield Zotero.Items.erase([1, 2], { skipDeleteLog: true });
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
// Create objects added before the last classic sync time,
// which should end up marked as synced
for (let type of types) {
objects[type] = [yield createDataObject(type)];
}
var time1 = "2015-05-01 01:23:45";
yield Zotero.DB.queryAsync("UPDATE collections SET clientDateModified=?", time1);
yield Zotero.DB.queryAsync("UPDATE savedSearches SET clientDateModified=?", time1);
yield Zotero.DB.queryAsync("UPDATE items SET clientDateModified=?", time1);
// Create objects added after the last sync time, which should be ignored and
// therefore end up marked as unsynced
for (let type of types) {
objects[type].push(yield createDataObject(type));
}
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
// Create JSON for objects created remotely after the last sync time,
// which should be ignored
objectJSON.collection.push(makeCollectionJSON({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
objectJSON.search.push(makeSearchJSON({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
objectJSON.item.push(makeItemJSON({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
itemType: "book",
title: Zotero.Utilities.randomString()
}));
var lastSyncTime = Zotero.Date.toUnixTimestamp(
Zotero.Date.sqlToDate("2015-05-02 00:00:00", true)
);
yield Zotero.DB.queryAsync(
"INSERT INTO version VALUES ('lastlocalsync', ?1), ('lastremotesync', ?1)",
lastSyncTime
);
var headers = {
"Last-Modified-Version": 20
}
for (let type of types) {
var suffix = type == 'item' ? '&includeTrashed=1' : '';
var json = {};
json[objects[type][0].key] = 10;
json[objectJSON[type][0].key] = objectJSON[type][0].version;
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=versions" + suffix,
status: 200,
headers: headers,
json: json
});
json = {};
json[objectJSON[type][0].key] = objectJSON[type][0].version;
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=versions&sincetime=" + lastSyncTime + suffix,
status: 200,
headers: headers,
json: json
});
}
var versionResults = yield engine._upgradeCheck();
// Objects 1 should be marked as synced, with versions from the server
// Objects 2 should be marked as unsynced
for (let type of types) {
var synced = yield Zotero.Sync.Data.Local.getSynced(userLibraryID, type);
assert.deepEqual(synced, [objects[type][0].key]);
assert.equal(objects[type][0].version, 10);
var unsynced = yield Zotero.Sync.Data.Local.getUnsynced(userLibraryID, type);
assert.deepEqual(unsynced, [objects[type][1].id]);
assert.equal(versionResults[type].libraryVersion, headers["Last-Modified-Version"]);
assert.property(versionResults[type].versions, objectJSON[type][0].key);
}
assert.equal(Zotero.Libraries.getVersion(userLibraryID), -1);
})
})
describe("#_fullSync()", function () {
it("should download missing/updated local objects and flag remotely missing local objects for upload", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
yield Zotero.Items.erase([1, 2], { skipDeleteLog: true });
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
for (let type of types) {
// Create objects with outdated versions, which should be updated
let obj = createUnsavedDataObject(type);
obj.synced = true;
obj.version = 5;
yield obj.saveTx();
objects[type] = [obj];
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: 20,
name: Zotero.Utilities.randomString()
}));
// Create JSON for objects that exist remotely and not locally,
// which should be downloaded
objectJSON[type].push(makeJSONFunctions[type]({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
// Create objects marked as synced that don't exist remotely,
// which should be flagged for upload
obj = createUnsavedDataObject(type);
obj.synced = true;
obj.version = 10;
yield obj.saveTx();
objects[type].push(obj);
}
var headers = {
"Last-Modified-Version": 20
}
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {
tagColors: {
value: [
{
name: "A",
color: "#CC66CC"
}
],
version: 2
}
}
});
for (let type of types) {
var suffix = type == 'item' ? '&includeTrashed=1' : '';
var json = {};
json[objectJSON[type][0].key] = objectJSON[type][0].version;
json[objectJSON[type][1].key] = objectJSON[type][1].version;
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=versions" + suffix,
status: 200,
headers: headers,
json: json
});
setResponse({
method: "GET",
url: "users/1/" + Zotero.DataObjectUtilities.getObjectTypePlural(type)
+ "?format=json"
+ "&" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key
+ suffix,
status: 200,
headers: headers,
json: objectJSON[type]
});
}
yield engine._fullSync();
// Check settings
var setting = yield Zotero.SyncedSettings.get(userLibraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = yield Zotero.SyncedSettings.getMetadata(userLibraryID, "tagColors");
assert.equal(settingMetadata.version, 2);
assert.isTrue(settingMetadata.synced);
// Check objects
for (let type of types) {
// Objects 1 should be updated with version from server
assert.equal(objects[type][0].version, 20);
assert.isTrue(objects[type][0].synced);
// JSON objects 1 should be created locally with version from server
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
let obj = objectsClass.getByLibraryAndKey(userLibraryID, objectJSON[type][0].key);
assert.equal(obj.version, 20);
assert.isTrue(obj.synced);
// JSON objects 2 should be marked as unsynced, with their version reset to 0
assert.equal(objects[type][1].version, 0);
assert.isFalse(objects[type][1].synced);
}
})
})
})

984
test/tests/syncLocalTest.js Normal file
View file

@ -0,0 +1,984 @@
"use strict";
describe("Zotero.Sync.Data.Local", function() {
describe("#processSyncCacheForObjectType()", function () {
var types = Zotero.DataObjectUtilities.getTypes();
it("should update local version number if remote version is identical", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
for (let type of types) {
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
let obj = yield createDataObject(type);
let data = yield obj.toJSON();
data.key = obj.key;
data.version = 10;
let json = {
key: obj.key,
version: 10,
data: data
};
yield Zotero.Sync.Data.Local.saveCacheObjects(
type, libraryID, [json]
);
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
assert.equal(
objectsClass.getByLibraryAndKey(libraryID, obj.key).version, 10
);
}
})
})
describe("#_reconcileChanges()", function () {
describe("items", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 1",
url: "http://zotero.org/",
publicationTitle: "Publisher", // Remove locally
extra: "Extra", // Removed on both
dateModified: "2015-05-14 12:34:56",
collections: [
'AAAAAAAA', // Removed locally
'DDDDDDDD', // Removed remotely,
'EEEEEEEE' // Removed from both
],
relations: {
a: 'A', // Unchanged string
c: ['C1', 'C2'], // Unchanged array
d: 'D', // String removed locally
e: ['E'], // Array removed locally
f: 'F1', // String changed locally
g: [
'G1', // Unchanged
'G2', // Removed remotely
'G3' // Removed from both
],
h: 'H', // String removed remotely
i: ['I'], // Array removed remotely
},
tags: [
{ tag: 'A' }, // Removed locally
{ tag: 'D' }, // Removed remotely
{ tag: 'E' } // Removed from both
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 2", // Changed locally
url: "https://www.zotero.org/", // Same change on local and remote
place: "Place", // Added locally
dateModified: "2015-05-14 14:12:34", // Changed locally and remotely, but ignored
collections: [
'BBBBBBBB', // Added locally
'DDDDDDDD',
'FFFFFFFF' // Added on both
],
relations: {
'a': 'A',
'b': 'B', // String added locally
'f': 'F2',
'g': [
'G1',
'G2',
'G6' // Added locally and remotely
],
h: 'H', // String removed remotely
i: ['I'], // Array removed remotely
},
tags: [
{ tag: 'B' },
{ tag: 'D' },
{ tag: 'F', type: 1 }, // Added on both
{ tag: 'G' }, // Added on both, but with different types
{ tag: 'H', type: 1 } // Added on both, but with different types
]
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
itemType: "book",
title: "Title 1",
url: "https://www.zotero.org/",
publicationTitle: "Publisher",
date: "2015-05-15", // Added remotely
dateModified: "2015-05-14 13:45:12",
collections: [
'AAAAAAAA',
'CCCCCCCC', // Added remotely
'FFFFFFFF'
],
relations: {
'a': 'A',
'd': 'D',
'e': ['E'],
'f': 'F1',
'g': [
'G1',
'G4', // Added remotely
'G6'
],
},
tags: [
{ tag: 'A' },
{ tag: 'C' },
{ tag: 'F', type: 1 },
{ tag: 'G', type: 1 },
{ tag: 'H' }
]
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', cacheJSON, json1, json2, ignoreFields
);
assert.sameDeepMembers(
result.changes,
[
{
field: "date",
op: "add",
value: "2015-05-15"
},
{
field: "collections",
op: "member-add",
value: "CCCCCCCC"
},
{
field: "collections",
op: "member-remove",
value: "DDDDDDDD"
},
// Relations
{
field: "relations",
op: "property-member-remove",
value: {
key: 'g',
value: 'G2'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'g',
value: 'G4'
}
},
{
field: "relations",
op: "property-member-remove",
value: {
key: 'h',
value: 'H'
}
},
{
field: "relations",
op: "property-member-remove",
value: {
key: 'i',
value: 'I'
}
},
// Tags
{
field: "tags",
op: "member-add",
value: {
tag: 'C'
}
},
{
field: "tags",
op: "member-remove",
value: {
tag: 'D'
}
},
{
field: "tags",
op: "member-remove",
value: {
tag: 'H',
type: 1
}
},
{
field: "tags",
op: "member-add",
value: {
tag: 'H'
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should return empty arrays when no remote changes to apply", function () {
// Similar to above but without differing remote changes
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 1",
url: "http://zotero.org/",
publicationTitle: "Publisher", // Remove locally
extra: "Extra", // Removed on both
dateModified: "2015-05-14 12:34:56",
collections: [
'AAAAAAAA', // Removed locally
'DDDDDDDD',
'EEEEEEEE' // Removed from both
],
tags: [
{
tag: 'A' // Removed locally
},
{
tag: 'D' // Removed remotely
},
{
tag: 'E' // Removed from both
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title 2", // Changed locally
url: "https://www.zotero.org/", // Same change on local and remote
place: "Place", // Added locally
dateModified: "2015-05-14 14:12:34", // Changed locally and remotely, but ignored
collections: [
'BBBBBBBB', // Added locally
'DDDDDDDD',
'FFFFFFFF' // Added on both
],
tags: [
{
tag: 'B'
},
{
tag: 'D'
},
{
tag: 'F', // Added on both
type: 1
},
{
tag: 'G' // Added on both, but with different types
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
itemType: "book",
title: "Title 1",
url: "https://www.zotero.org/",
publicationTitle: "Publisher",
dateModified: "2015-05-14 13:45:12",
collections: [
'AAAAAAAA',
'DDDDDDDD',
'FFFFFFFF'
],
tags: [
{
tag: 'A'
},
{
tag: 'D'
},
{
tag: 'F',
type: 1
},
{
tag: 'G',
type: 1
}
]
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', cacheJSON, json1, json2, ignoreFields
);
assert.lengthOf(result.changes, 0);
assert.lengthOf(result.conflicts, 0);
})
it("should return conflict when changes can't be automatically resolved", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
title: "Title 1",
dateModified: "2015-05-14 12:34:56"
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
title: "Title 2",
dateModified: "2015-05-14 14:12:34"
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
title: "Title 3",
dateModified: "2015-05-14 13:45:12"
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', cacheJSON, json1, json2, ignoreFields
);
Zotero.debug('=-=-=-=');
Zotero.debug(result);
assert.lengthOf(result.changes, 0);
assert.sameDeepMembers(
result.conflicts,
[
[
{
field: "title",
op: "modify",
value: "Title 2"
},
{
field: "title",
op: "modify",
value: "Title 3"
}
]
]
);
})
it("should automatically merge array/object members and generate conflicts for field changes in absence of cached version", function () {
var json1 = {
key: "AAAAAAAA",
version: 1234,
itemType: "book",
title: "Title",
creators: [
{
name: "Center for History and New Media",
creatorType: "author"
}
],
place: "Place", // Local
dateModified: "2015-05-14 14:12:34", // Changed on both, but ignored
collections: [
'AAAAAAAA' // Local
],
relations: {
'a': 'A',
'b': 'B', // Local
'e': 'E1',
'f': [
'F1',
'F2' // Local
],
h: 'H', // String removed remotely
i: ['I'], // Array removed remotely
},
tags: [
{ tag: 'A' }, // Local
{ tag: 'C' },
{ tag: 'F', type: 1 },
{ tag: 'G' }, // Different types
{ tag: 'H', type: 1 } // Different types
]
};
var json2 = {
key: "AAAAAAAA",
version: 1235,
itemType: "book",
title: "Title",
creators: [
{
creatorType: "author", // Different property order shouldn't matter
name: "Center for History and New Media"
}
],
date: "2015-05-15", // Remote
dateModified: "2015-05-14 13:45:12",
collections: [
'BBBBBBBB' // Remote
],
relations: {
'a': 'A',
'c': 'C', // Remote
'd': ['D'], // Remote
'e': 'E2',
'f': [
'F1',
'F3' // Remote
],
},
tags: [
{ tag: 'B' }, // Remote
{ tag: 'C' },
{ tag: 'F', type: 1 },
{ tag: 'G', type: 1 }, // Different types
{ tag: 'H' } // Different types
]
};
var ignoreFields = ['dateAdded', 'dateModified'];
var result = Zotero.Sync.Data.Local._reconcileChanges(
'item', false, json1, json2, ignoreFields
);
Zotero.debug(result);
assert.sameDeepMembers(
result.changes,
[
// Collections
{
field: "collections",
op: "member-add",
value: "BBBBBBBB"
},
// Relations
{
field: "relations",
op: "property-member-add",
value: {
key: 'c',
value: 'C'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'd',
value: 'D'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'e',
value: 'E2'
}
},
{
field: "relations",
op: "property-member-add",
value: {
key: 'f',
value: 'F3'
}
},
// Tags
{
field: "tags",
op: "member-add",
value: {
tag: 'B'
}
},
{
field: "tags",
op: "member-add",
value: {
tag: 'G',
type: 1
}
},
{
field: "tags",
op: "member-add",
value: {
tag: 'H'
}
}
]
);
assert.sameDeepMembers(
result.conflicts,
[
{
field: "place",
op: "delete"
},
{
field: "date",
op: "add",
value: "2015-05-15"
}
]
);
})
})
describe("collections", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
parentCollection: null,
relations: {
A: "A", // Removed locally
C: "C" // Removed on both
}
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
parentCollection: null,
relations: {}
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
parentCollection: "BBBBBBBB", // Added remotely
relations: {
A: "A",
B: "B" // Added remotely
}
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'collection', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "parentCollection",
op: "add",
value: "BBBBBBBB"
},
{
field: "relations",
op: "property-member-add",
value: {
key: "B",
value: "B"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should return empty arrays when no remote changes to apply", function () {
// Similar to above but without differing remote changes
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Added locally
{
condition: "place",
operator: "is",
value: "New York"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.lengthOf(result.changes, 0);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts with remote version", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1"
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2"
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 3"
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 3"
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts in absence of cached version", function () {
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "New York"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', false, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 2"
},
{
field: "conditions",
op: "member-add",
value: {
condition: "place",
operator: "is",
value: "Chicago"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
})
describe("searches", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Removed remotely
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Added remotely
{
condition: "place",
operator: "is",
value: "New York"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "conditions",
op: "member-add",
value: {
condition: "place",
operator: "is",
value: "New York"
}
},
{
field: "conditions",
op: "member-remove",
value: {
condition: "place",
operator: "is",
value: "Chicago"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should return empty arrays when no remote changes to apply", function () {
// Similar to above but without differing remote changes
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2", // Changed locally
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
// Added locally
{
condition: "place",
operator: "is",
value: "New York"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.lengthOf(result.changes, 0);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts with remote version", function () {
var cacheJSON = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1"
};
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2"
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 3"
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', cacheJSON, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 3"
}
]
);
assert.lengthOf(result.conflicts, 0);
})
it("should automatically resolve conflicts in absence of cached version", function () {
var json1 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 1",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "New York"
}
]
};
var json2 = {
key: "AAAAAAAA",
version: 1234,
name: "Name 2",
conditions: [
{
condition: "title",
operator: "contains",
value: "A"
},
{
condition: "place",
operator: "is",
value: "Chicago"
}
]
};
var result = Zotero.Sync.Data.Local._reconcileChanges(
'search', false, json1, json2
);
assert.sameDeepMembers(
result.changes,
[
{
field: "name",
op: "modify",
value: "Name 2"
},
{
field: "conditions",
op: "member-add",
value: {
condition: "place",
operator: "is",
value: "Chicago"
}
}
]
);
assert.lengthOf(result.conflicts, 0);
})
})
})
})

View file

@ -0,0 +1,653 @@
"use strict";
describe("Zotero.Sync.Runner", function () {
var apiKey = Zotero.Utilities.randomString(24);
var baseURL = "http://local.zotero/";
var userLibraryID, publicationsLibraryID, runner, caller, server, client, stub, spy;
var responses = {
keyInfo: {
fullAccess: {
method: "GET",
url: "keys/" + apiKey,
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
//
var setup = Zotero.Promise.coroutine(function* (options = {}) {
yield Zotero.DB.queryAsync("DELETE FROM settings WHERE setting='account'");
yield Zotero.Users.init();
var runner = new Zotero.Sync.Runner_Module({
baseURL: baseURL,
apiKey: apiKey
});
Components.utils.import("resource://zotero/concurrent-caller.js");
var 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;
}
};
var client = new Zotero.Sync.APIClient({
baseURL: baseURL,
apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
apiKey: apiKey,
concurrentCaller: caller,
background: options.background || true
});
return { runner, caller, client };
})
function setResponse(response) {
setHTTPResponse(server, baseURL, response, responses);
}
//
// Tests
//
before(function () {
userLibraryID = Zotero.Libraries.userLibraryID;
publicationsLibraryID = Zotero.Libraries.publicationsLibraryID;
})
beforeEach(function* () {
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
server = sinon.fakeServer.create();
server.autoRespond = true;
({ runner, caller, client } = yield setup());
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", function* () {
spy = sinon.spy(runner, "checkUser");
setResponse('keyInfo.fullAccess');
var json = yield runner.checkAccess(client);
sinon.assert.calledWith(spy, 1, "Username");
var compare = {};
Object.assign(compare, responses.keyInfo.fullAccess.json);
delete compare.key;
assert.deepEqual(json, compare);
})
})
describe("#checkLibraries()", function () {
afterEach(function* () {
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", function* () {
// Create group with same id and version as groups response
var groupData = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
groupData = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
setResponse('userGroups.groupVersions');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 4);
assert.sameMembers(
libraries,
[userLibraryID, publicationsLibraryID, group1.libraryID, group2.libraryID]
);
})
it("should check library access and versions with library list", function* () {
// Create groups with same id and version as groups response
var groupData = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
groupData = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData.json.id,
version: groupData.json.version
});
setResponse('userGroups.groupVersions');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json, [userLibraryID]
);
assert.lengthOf(libraries, 1);
assert.sameMembers(libraries, [userLibraryID]);
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json, [userLibraryID, publicationsLibraryID]
);
assert.lengthOf(libraries, 2);
assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID]);
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json, [group1.libraryID]
);
assert.lengthOf(libraries, 1);
assert.sameMembers(libraries, [group1.libraryID]);
})
it("should update outdated group metadata", function* () {
// Create groups with same id as groups response but earlier versions
var groupData1 = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData1.json.id,
version: groupData1.json.version - 1,
editable: false
});
var groupData2 = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData2.json.id,
version: groupData2.json.version - 1,
editable: true
});
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 4);
assert.sameMembers(
libraries,
[userLibraryID, publicationsLibraryID, 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", function* () {
var groupData1 = responses.groups.ownerGroup;
var group1 = yield createGroup({
id: groupData1.json.id,
version: 0,
editable: false
});
var groupData2 = responses.groups.memberGroup;
var group2 = yield createGroup({
id: groupData2.json.id,
version: 0,
editable: true
});
yield Zotero.DB.queryAsync(
"UPDATE groups SET version=0 WHERE groupID IN (?, ?)", [group1.id, group2.id]
);
yield Zotero.Groups.init();
group1 = Zotero.Groups.get(group1.id);
group2 = Zotero.Groups.get(group2.id);
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
var libraries = yield runner.checkLibraries(
client,
false,
responses.keyInfo.fullAccess.json,
[group1.libraryID, group2.libraryID]
);
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", function* () {
setResponse('userGroups.groupVersions');
setResponse('groups.ownerGroup');
setResponse('groups.memberGroup');
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 4);
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, publicationsLibraryID, 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", function* () {
var groupData1 = responses.groups.ownerGroup;
var group1 = yield createGroup({ id: groupData1.json.id, version: groupData1.json.version });
var groupData2 = responses.groups.memberGroup;
var group2 = yield 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 = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 3);
assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID, group2.libraryID]);
assert.isFalse(Zotero.Groups.exists(groupData1.json.id));
assert.isTrue(Zotero.Groups.exists(groupData2.json.id));
})
it.skip("should keep remotely missing groups", function* () {
var groupData = responses.groups.ownerGroup;
var group = yield 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);
}, "extra1");
var libraries = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 3);
assert.sameMembers(libraries, [userLibraryID, publicationsLibraryID, group.libraryID]);
assert.isTrue(Zotero.Groups.exists(groupData.json.id));
})
it("should cancel sync with remotely missing groups", function* () {
var groupData = responses.groups.ownerGroup;
var group = yield 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 = yield runner.checkLibraries(
client, false, responses.keyInfo.fullAccess.json
);
assert.lengthOf(libraries, 0);
assert.isTrue(Zotero.Groups.exists(groupData.json.id));
})
})
describe("#checkUser()", function () {
it("should prompt for user update and perform on accept", function* () {
waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
var matches = text.match(/'[^']*'/g);
assert.equal(matches.length, 4);
assert.equal(matches[0], "'A'");
assert.equal(matches[1], "'B'");
assert.equal(matches[2], "'B'");
assert.equal(matches[3], "'A'");
});
var cont = yield runner.checkUser(2, "B");
assert.isTrue(cont);
assert.equal(Zotero.Users.getCurrentUserID(), 2);
assert.equal(Zotero.Users.getCurrentUsername(), "B");
})
it("should prompt for user update and cancel", function* () {
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A");
waitForDialog(false, 'cancel');
var cont = yield runner.checkUser(2, "B");
assert.isFalse(cont);
assert.equal(Zotero.Users.getCurrentUserID(), 1);
assert.equal(Zotero.Users.getCurrentUsername(), "A");
})
})
describe("#sync()", function () {
before(function* () {
this.timeout(60000);
yield resetDB({
skipBundledFiles: true
});
yield Zotero.Groups.init();
})
after(function* () {
this.timeout(60000);
yield resetDB();
})
it("should perform a sync across all libraries", function* () {
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("A");
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?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: []
});
// My Publications
setResponse({
method: "GET",
url: "users/1/publications/settings",
status: 200,
headers: {
"Last-Modified-Version": 10
},
json: []
});
setResponse({
method: "GET",
url: "users/1/publications/items?format=versions&includeTrashed=1",
status: 200,
headers: {
"Last-Modified-Version": 10
},
json: []
});
setResponse({
method: "GET",
url: "users/1/publications/deleted?since=0",
status: 200,
headers: {
"Last-Modified-Version": 10
},
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?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?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: []
});
yield runner.sync({
baseURL: baseURL,
apiKey: apiKey,
onError: e => { throw e },
});
// Check local library versions
assert.equal(
Zotero.Libraries.getVersion(Zotero.Libraries.userLibraryID),
5
);
assert.equal(
Zotero.Libraries.getVersion(Zotero.Libraries.publicationsLibraryID),
10
);
assert.equal(
Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(1623562)),
15
);
assert.equal(
Zotero.Libraries.getVersion(Zotero.Groups.getLibraryIDFromGroupID(2694172)),
20
);
})
})
})