zotero/test/tests/syncEngineTest.js
Dan Stillman a1bd2bace5 Remove objects from sync queue if missing from response
While objects in the sync queue that fail to save should remain in the
queue, objects that just don't exist remotely need to be removed, or
else they'll be retried forever.
2017-05-05 01:04:16 -04:00

3416 lines
94 KiB
JavaScript

"use strict";
describe("Zotero.Sync.Data.Engine", function () {
Components.utils.import("resource://zotero/config.js");
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 = {}) {
server = sinon.fakeServer.create();
server.autoRespond = true;
var background = options.background === undefined ? true : options.background;
var stopOnError = options.stopOnError === undefined ? true : options.stopOnError;
Components.utils.import("resource://zotero/concurrentCaller.js");
var caller = new ConcurrentCaller(1);
caller.setLogger(msg => Zotero.debug(msg));
caller.stopOnError = stopOnError;
var client = new Zotero.Sync.APIClient({
baseURL,
apiVersion: options.apiVersion || ZOTERO_CONFIG.API_VERSION,
apiKey,
caller,
background
});
var engine = new Zotero.Sync.Data.Engine({
apiClient: client,
libraryID: options.libraryID || Zotero.Libraries.userLibraryID,
stopOnError
});
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,
parentCollection: options.parentCollection
}
};
}
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
};
var assertInCache = Zotero.Promise.coroutine(function* (obj) {
var cacheObject = yield Zotero.Sync.Data.Local.getCacheObject(
obj.objectType, obj.libraryID, obj.key, obj.version
);
assert.isObject(cacheObject);
assert.propertyVal(cacheObject, 'key', obj.key);
});
var assertNotInCache = Zotero.Promise.coroutine(function* (obj) {
assert.isFalse(yield Zotero.Sync.Data.Local.getCacheObject(
obj.objectType, obj.libraryID, obj.key, obj.version
));
});
//
// Tests
//
beforeEach(function* () {
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
yield Zotero.Users.setCurrentUserID(1);
yield Zotero.Users.setCurrentUsername("testuser");
})
after(function () {
Zotero.HTTP.mock = null;
});
describe("Syncing", function () {
it("should download items into a new library", function* () {
({ engine, client, caller } = yield setup());
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/top?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 3
}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 3,
"BBBBBBBB": 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/items?format=json&itemKey=BBBBBBBB&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "BBBBBBBB",
version: 3,
itemType: "note",
parentItem: "AAAAAAAA",
note: "This is a note."
})
]
});
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 = Zotero.SyncedSettings.get(userLibraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = 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);
yield assertInCache(obj);
obj = yield Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 2);
assert.isTrue(obj.synced);
yield assertInCache(obj);
obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.getField('title'), 'A');
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
var parentItemID = obj.id;
yield assertInCache(obj);
obj = yield Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "BBBBBBBB");
assert.equal(obj.getNote(), 'This is a note.');
assert.equal(obj.parentItemID, parentItemID);
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
yield assertInCache(obj);
})
it("should download items into a new read-only group", function* () {
var group = yield createGroup({
editable: false,
filesEditable: false
});
var libraryID = group.libraryID;
var itemToDelete = yield createDataObject(
'item', { libraryID, synced: true }, { skipEditCheck: true }
)
var itemToDeleteID = itemToDelete.id;
({ engine, client, caller } = yield setup({ libraryID }));
var headers = {
"Last-Modified-Version": 3
};
setResponse({
method: "GET",
url: `groups/${group.id}/settings`,
status: 200,
headers: headers,
json: {
tagColors: {
value: [
{
name: "A",
color: "#CC66CC"
}
],
version: 2
}
}
});
setResponse({
method: "GET",
url: `groups/${group.id}/collections?format=versions`,
status: 200,
headers: headers,
json: {
"AAAAAAAA": 1
}
});
setResponse({
method: "GET",
url: `groups/${group.id}/searches?format=versions`,
status: 200,
headers: headers,
json: {
"AAAAAAAA": 2
}
});
setResponse({
method: "GET",
url: `groups/${group.id}/items/top?format=versions&includeTrashed=1`,
status: 200,
headers: headers,
json: {
"AAAAAAAA": 3
}
});
setResponse({
method: "GET",
url: `groups/${group.id}/items?format=versions&includeTrashed=1`,
status: 200,
headers: headers,
json: {
"AAAAAAAA": 3,
"BBBBBBBB": 3
}
});
setResponse({
method: "GET",
url: `groups/${group.id}/collections?format=json&collectionKey=AAAAAAAA`,
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
})
]
});
setResponse({
method: "GET",
url: `groups/${group.id}/searches?format=json&searchKey=AAAAAAAA`,
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "AAAAAAAA",
version: 2,
name: "A"
})
]
});
setResponse({
method: "GET",
url: `groups/${group.id}/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: `groups/${group.id}/items?format=json&itemKey=BBBBBBBB&includeTrashed=1`,
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "BBBBBBBB",
version: 3,
itemType: "note",
parentItem: "AAAAAAAA",
note: "This is a note."
})
]
});
setResponse({
method: "GET",
url: `groups/${group.id}/deleted?since=0`,
status: 200,
headers: headers,
json: {
"items": [itemToDelete.key]
}
});
yield engine.start();
// Check local library version
assert.equal(group.libraryVersion, 3);
// Make sure local objects exist
var setting = Zotero.SyncedSettings.get(libraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = Zotero.SyncedSettings.getMetadata(libraryID, "tagColors");
assert.equal(settingMetadata.version, 2);
assert.isTrue(settingMetadata.synced);
var obj = Zotero.Collections.getByLibraryAndKey(libraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 1);
assert.isTrue(obj.synced);
yield assertInCache(obj);
obj = Zotero.Searches.getByLibraryAndKey(libraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 2);
assert.isTrue(obj.synced);
yield assertInCache(obj);
obj = Zotero.Items.getByLibraryAndKey(libraryID, "AAAAAAAA");
assert.equal(obj.getField('title'), 'A');
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
var parentItemID = obj.id;
yield assertInCache(obj);
obj = Zotero.Items.getByLibraryAndKey(libraryID, "BBBBBBBB");
assert.equal(obj.getNote(), 'This is a note.');
assert.equal(obj.parentItemID, parentItemID);
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
yield assertInCache(obj);
assert.isFalse(Zotero.Items.exists(itemToDeleteID));
});
it("should upload new full items and subsequent patches", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
yield Zotero.SyncedSettings.set(libraryID, "testSetting1", { foo: "bar" });
yield Zotero.SyncedSettings.set(libraryID, "testSetting2", { bar: "foo" });
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectResponseJSON = {};
var objectVersions = {};
for (let type of types) {
objects[type] = [yield createDataObject(type, { setTitle: true })];
objectVersions[type] = {};
objectResponseJSON[type] = objects[type].map(o => o.toResponseJSON());
}
server.respond(function (req) {
if (req.method == "POST") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
// Both settings should be uploaded
if (req.url == baseURL + "users/1/settings") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(Object.keys(json), 2);
assert.property(json, "testSetting1");
assert.property(json, "testSetting2");
assert.property(json.testSetting1, "value");
assert.property(json.testSetting2, "value");
assert.propertyVal(json.testSetting1.value, "foo", "bar");
assert.propertyVal(json.testSetting2.value, "bar", "foo");
req.respond(
204,
{
"Last-Modified-Version": ++lastLibraryVersion
},
""
);
return;
}
for (let type of types) {
let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
if (req.url == baseURL + "users/1/" + typePlural) {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 1);
assert.equal(json[0].key, objects[type][0].key);
assert.equal(json[0].version, 0);
if (type == 'item') {
assert.equal(json[0].title, objects[type][0].getField('title'));
}
else {
assert.equal(json[0].name, objects[type][0].name);
}
let objectJSON = objectResponseJSON[type][0];
objectJSON.version = ++lastLibraryVersion;
objectJSON.data.version = lastLibraryVersion;
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": objectJSON
},
unchanged: {},
failed: {}
})
);
objectVersions[type][objects[type][0].key] = lastLibraryVersion;
return;
}
}
}
})
yield engine.start();
yield Zotero.SyncedSettings.set(libraryID, "testSetting2", { bar: "bar" });
assert.equal(Zotero.Libraries.getVersion(libraryID), lastLibraryVersion);
for (let type of types) {
// Make sure objects were set to the correct version and marked as synced
assert.lengthOf((yield Zotero.Sync.Data.Local.getUnsynced(type, libraryID)), 0);
let key = objects[type][0].key;
let version = objects[type][0].version;
assert.equal(version, objectVersions[type][key]);
// Make sure uploaded objects were added to cache
let cached = yield Zotero.Sync.Data.Local.getCacheObject(type, libraryID, key, version);
assert.typeOf(cached, 'object');
assert.equal(cached.key, key);
assert.equal(cached.version, version);
yield modifyDataObject(objects[type][0]);
}
({ engine, client, caller } = yield setup());
server.respond(function (req) {
if (req.method == "POST") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
// Modified setting should be uploaded
if (req.url == baseURL + "users/1/settings") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(Object.keys(json), 1);
assert.property(json, "testSetting2");
assert.property(json.testSetting2, "value");
assert.propertyVal(json.testSetting2.value, "bar", "bar");
req.respond(
204,
{
"Last-Modified-Version": ++lastLibraryVersion
},
""
);
return;
}
for (let type of types) {
let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
if (req.url == baseURL + "users/1/" + typePlural) {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 1);
let j = json[0];
let o = objects[type][0];
assert.equal(j.key, o.key);
assert.equal(j.version, objectVersions[type][o.key]);
if (type == 'item') {
assert.equal(j.title, o.getField('title'));
}
else {
assert.equal(j.name, o.name);
}
// Verify PATCH semantics instead of POST (i.e., only changed fields)
let changedFieldsExpected = ['key', 'version'];
if (type == 'item') {
changedFieldsExpected.push('title', 'dateModified');
}
else {
changedFieldsExpected.push('name');
}
let changedFields = Object.keys(j);
assert.lengthOf(
changedFields, changedFieldsExpected.length, "same " + type + " length"
);
assert.sameMembers(
changedFields, changedFieldsExpected, "same " + type + " members"
);
let objectJSON = objectResponseJSON[type][0];
objectJSON.version = ++lastLibraryVersion;
objectJSON.data.version = lastLibraryVersion;
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": objectJSON
},
unchanged: {},
failed: {}
})
);
objectVersions[type][o.key] = lastLibraryVersion;
return;
}
}
}
})
yield engine.start();
assert.equal(Zotero.Libraries.getVersion(libraryID), lastLibraryVersion);
for (let type of types) {
// Make sure objects were set to the correct version and marked as synced
assert.lengthOf((yield Zotero.Sync.Data.Local.getUnsynced(type, libraryID)), 0);
let o = objects[type][0];
let key = o.key;
let version = o.version;
assert.equal(version, objectVersions[type][key]);
// Make sure uploaded objects were added to cache
let cached = yield Zotero.Sync.Data.Local.getCacheObject(type, libraryID, key, version);
assert.typeOf(cached, 'object');
assert.equal(cached.key, key);
assert.equal(cached.version, version);
switch (type) {
case 'collection':
assert.isFalse(cached.data.parentCollection);
break;
case 'item':
assert.equal(cached.data.dateAdded, Zotero.Date.sqlToISO8601(o.dateAdded));
break;
case 'search':
assert.isArray(cached.data.conditions);
break;
}
// Make sure older versions have been removed from the cache
let versions = yield Zotero.Sync.Data.Local.getCacheObjectVersions(type, libraryID, key);
assert.sameMembers(versions, [version]);
}
})
it("should upload child item after parent item", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
// Create top-level note, book, and child note
var item1 = new Zotero.Item('note');
item1.setNote('A');
yield item1.saveTx();
var item2 = yield createDataObject('item');
var item3 = new Zotero.Item('note');
item3.parentItemID = item2.id;
item3.setNote('B');
yield item3.saveTx();
// Move note under parent
item1.parentItemID = item2.id;
yield item1.saveTx();
var handled = false;
server.respond(function (req) {
if (req.method == "POST" && req.url == baseURL + "users/1/items") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 3);
assert.equal(json[0].key, item2.key);
assert.equal(json[1].key, item1.key);
assert.equal(json[2].key, item3.key);
handled = true;
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({
successful: {
"0": item2.toResponseJSON(),
"1": item1.toResponseJSON(),
"2": item3.toResponseJSON()
},
unchanged: {},
failed: {}
})
);
return;
}
});
yield engine.start();
assert.isTrue(handled);
});
it("should upload child collection after parent collection", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var collection1 = yield createDataObject('collection');
var collection2 = yield createDataObject('collection');
var collection3 = yield createDataObject('collection', { parentID: collection2.id });
// Move collection under the other
collection1.parentID = collection2.id;
yield collection1.saveTx();
var handled = false;
server.respond(function (req) {
if (req.method == "POST" && req.url == baseURL + "users/1/collections") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 3);
assert.equal(json[0].key, collection2.key);
assert.equal(json[1].key, collection1.key);
assert.equal(json[2].key, collection3.key);
handled = true;
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({
successful: {
"0": collection2.toResponseJSON(),
"1": collection1.toResponseJSON(),
"2": collection3.toResponseJSON()
},
unchanged: {},
failed: {}
})
);
return;
}
});
yield engine.start();
assert.isTrue(handled);
});
it("should update library version after settings upload", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" });
server.respond(function (req) {
if (req.method == "POST") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
if (req.url == baseURL + "users/1/settings") {
let json = JSON.parse(req.requestBody);
req.respond(
204,
{
"Last-Modified-Version": ++lastLibraryVersion
},
""
);
return;
}
}
})
yield engine.start();
assert.isAbove(library.libraryVersion, 5);
assert.equal(library.libraryVersion, lastLibraryVersion);
})
it("shouldn't include mtime and md5 for attachments in ZFS libraries", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var item = new Zotero.Item('attachment');
item.attachmentLinkMode = 'imported_file';
item.attachmentFilename = 'test.txt';
item.attachmentContentType = 'text/plain';
item.attachmentCharset = 'utf-8';
yield item.saveTx();
var itemResponseJSON = item.toResponseJSON();
itemResponseJSON.version = itemResponseJSON.data.version = lastLibraryVersion;
server.respond(function (req) {
if (req.method == "POST") {
if (req.url == baseURL + "users/1/items") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 1);
let itemJSON = json[0];
assert.equal(itemJSON.key, item.key);
assert.equal(itemJSON.version, 0);
assert.property(itemJSON, "contentType");
assert.property(itemJSON, "charset");
assert.property(itemJSON, "filename");
assert.notProperty(itemJSON, "mtime");
assert.notProperty(itemJSON, "md5");
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": itemResponseJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
yield engine.start();
});
it("should include storage properties for attachments in WebDAV libraries", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
Zotero.Sync.Storage.Local.setModeForLibrary(library.id, 'webdav');
var item = new Zotero.Item('attachment');
item.attachmentLinkMode = 'imported_file';
item.attachmentFilename = 'test.txt';
item.attachmentContentType = 'text/plain';
item.attachmentCharset = 'utf-8';
yield item.saveTx();
var itemResponseJSON = item.toResponseJSON();
itemResponseJSON.version = itemResponseJSON.data.version = lastLibraryVersion;
server.respond(function (req) {
if (req.method == "POST") {
if (req.url == baseURL + "users/1/items") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 1);
let itemJSON = json[0];
assert.equal(itemJSON.key, item.key);
assert.equal(itemJSON.version, 0);
assert.propertyVal(itemJSON, "contentType", item.attachmentContentType);
assert.propertyVal(itemJSON, "charset", item.attachmentCharset);
assert.propertyVal(itemJSON, "filename", item.attachmentFilename);
assert.propertyVal(itemJSON, "mtime", null);
assert.propertyVal(itemJSON, "md5", null);
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": itemResponseJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
yield engine.start();
});
it("should include mtime and md5 synced to WebDAV in WebDAV libraries", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
Zotero.Sync.Storage.Local.setModeForLibrary(library.id, 'webdav');
var item = new Zotero.Item('attachment');
item.attachmentLinkMode = 'imported_file';
item.attachmentFilename = 'test1.txt';
yield item.saveTx();
var mtime = new Date().getTime();
var md5 = '57f8a4fda823187b91e1191487b87fe6';
item.attachmentSyncedModificationTime = mtime;
item.attachmentSyncedHash = md5;
yield item.saveTx({ skipAll: true });
var itemResponseJSON = item.toResponseJSON();
itemResponseJSON.version = itemResponseJSON.data.version = lastLibraryVersion;
itemResponseJSON.data.mtime = mtime;
itemResponseJSON.data.md5 = md5;
server.respond(function (req) {
if (req.method == "POST") {
if (req.url == baseURL + "users/1/items") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 1);
let itemJSON = json[0];
assert.equal(itemJSON.key, item.key);
assert.equal(itemJSON.version, 0);
assert.equal(itemJSON.mtime, mtime);
assert.equal(itemJSON.md5, md5);
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": itemResponseJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
yield engine.start();
// Check data in cache
var json = yield Zotero.Sync.Data.Local.getCacheObject(
'item', library.id, item.key, lastLibraryVersion
);
assert.equal(json.data.mtime, mtime);
assert.equal(json.data.md5, md5);
})
it("should update local objects with remotely saved version after uploading if necessary", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectResponseJSON = {};
var objectNames = {};
var itemDateModified = {};
for (let type of types) {
objects[type] = [
yield createDataObject(
type, { setTitle: true, dateModified: '2016-05-21 01:00:00' }
)
];
objectNames[type] = {};
objectResponseJSON[type] = objects[type].map(o => o.toResponseJSON());
if (type == 'item') {
let item = objects[type][0];
itemDateModified[item.key] = item.dateModified;
}
}
server.respond(function (req) {
if (req.method == "POST") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
for (let type of types) {
let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
if (req.url == baseURL + "users/1/" + typePlural) {
let key = objects[type][0].key;
let objectJSON = objectResponseJSON[type][0];
objectJSON.version = ++lastLibraryVersion;
objectJSON.data.version = lastLibraryVersion;
let prop = type == 'item' ? 'title' : 'name';
objectNames[type][key] = objectJSON.data[prop] = Zotero.Utilities.randomString();
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": objectJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
}
})
yield engine.start();
assert.equal(library.libraryVersion, lastLibraryVersion);
for (let type of types) {
// Make sure local objects were updated with new metadata and marked as synced
assert.lengthOf((yield Zotero.Sync.Data.Local.getUnsynced(type, libraryID)), 0);
let o = objects[type][0];
let key = o.key;
let version = o.version;
let name = objectNames[type][key];
if (type == 'item') {
assert.equal(name, o.getField('title'));
// But Date Modified shouldn't have changed for items
assert.equal(itemDateModified[key], o.dateModified);
}
else {
assert.equal(name, o.name);
}
}
})
it("should upload local deletions", function* () {
var { engine, client, caller } = yield setup();
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
for (let type of types) {
let obj1 = yield createDataObject(type);
let obj2 = yield createDataObject(type);
objects[type] = [obj1.key, obj2.key];
yield obj1.eraseTx();
yield obj2.eraseTx();
}
var count = types.length;
server.respond(function (req) {
if (req.method == "DELETE") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
// TODO: Settings?
// Data objects
for (let type of types) {
let typePlural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
if (req.url.startsWith(baseURL + "users/1/" + typePlural)) {
let matches = req.url.match(new RegExp("\\?" + type + "Key=(.+)"));
let keys = decodeURIComponent(matches[1]).split(',');
assert.sameMembers(keys, objects[type]);
req.respond(
204,
{
"Last-Modified-Version": ++lastLibraryVersion
}
);
count--;
return;
}
}
}
})
yield engine.start();
assert.equal(count, 0);
for (let type of types) {
yield assert.eventually.lengthOf(
Zotero.Sync.Data.Local.getDeleted(type, library.id), 0
);
}
assert.equal(library.libraryVersion, lastLibraryVersion);
})
it("should make only one request if in sync", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ 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();
})
it("should ignore errors when saving downloaded objects", function* () {
({ engine, client, caller } = yield setup({
stopOnError: false
}));
var headers = {
"Last-Modified-Version": 3
};
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers: headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions",
status: 200,
headers: headers,
json: {
"AAAAAAAA": 1,
"BBBBBBBB": 1,
"CCCCCCCC": 1
}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions",
status: 200,
headers: headers,
json: {
"DDDDDDDD": 2,
"EEEEEEEE": 2,
"FFFFFFFF": 2
}
});
setResponse({
method: "GET",
url: "users/1/items/top?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"GGGGGGGG": 3,
"HHHHHHHH": 3
}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&includeTrashed=1",
status: 200,
headers: headers,
json: {
"GGGGGGGG": 3,
"HHHHHHHH": 3,
"JJJJJJJJ": 3
}
});
setResponse({
method: "GET",
url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC",
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
}),
makeCollectionJSON({
key: "BBBBBBBB",
version: 1,
name: "B",
// Missing parent -- collection should be queued
parentCollection: "ZZZZZZZZ"
}),
makeCollectionJSON({
key: "CCCCCCCC",
version: 1,
name: "C",
// Unknown field -- should be ignored
unknownField: 5
})
]
});
setResponse({
method: "GET",
url: "users/1/searches?format=json&searchKey=DDDDDDDD%2CEEEEEEEE%2CFFFFFFFF",
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "DDDDDDDD",
version: 2,
name: "D",
conditions: [
{
condition: "title",
operator: "is",
value: "a"
}
]
}),
makeSearchJSON({
key: "EEEEEEEE",
version: 2,
name: "E",
conditions: [
{
// Unknown search condition -- search should be queued
condition: "unknownCondition",
operator: "is",
value: "a"
}
]
}),
makeSearchJSON({
key: "FFFFFFFF",
version: 2,
name: "F",
conditions: [
{
condition: "title",
// Unknown search operator -- search should be queued
operator: "unknownOperator",
value: "a"
}
]
})
]
});
setResponse({
method: "GET",
url: "users/1/items?format=json&itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "GGGGGGGG",
version: 3,
itemType: "book",
title: "G",
// Unknown item field -- should be ignored
unknownField: "B"
}),
makeItemJSON({
key: "HHHHHHHH",
version: 3,
// Unknown item type -- item should be queued
itemType: "unknownItemType",
title: "H"
})
]
});
setResponse({
method: "GET",
url: "users/1/items?format=json&itemKey=JJJJJJJJ&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "JJJJJJJJ",
version: 3,
itemType: "note",
// Parent that couldn't be saved -- item should be queued
parentItem: "HHHHHHHH",
note: "This is a note."
})
]
});
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: {}
});
var spy = sinon.spy(engine, "onError");
yield engine.start();
var userLibraryID = Zotero.Libraries.userLibraryID;
// Library version should have been updated
assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
// Check for saved objects
yield assert.eventually.ok(Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA"));
yield assert.eventually.ok(Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "DDDDDDDD"));
yield assert.eventually.ok(Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "GGGGGGGG"));
// Check for queued objects
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', userLibraryID);
assert.sameMembers(keys, ['BBBBBBBB']);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('search', userLibraryID);
assert.sameMembers(keys, ['EEEEEEEE', 'FFFFFFFF']);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', userLibraryID);
assert.sameMembers(keys, ['HHHHHHHH', 'JJJJJJJJ']);
assert.equal(spy.callCount, 3);
});
it("should delay on second upload conflict", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
// Try to upload, get 412
// Download, get new version number
// Try to upload again, get 412
// Delay
// Download, get new version number
// Upload, get 200
var item = yield createDataObject('item');
var lastLibraryVersion = 5;
var calls = 0;
var t;
server.respond(function (req) {
if (req.method == "POST") {
calls++;
}
// On first and second upload attempts, return 412
if (req.method == "POST" && req.url.startsWith(baseURL + "users/1/items")) {
if (calls == 1 || calls == 2) {
if (calls == 2) {
assert.isAbove(new Date() - t, 50);
}
t = new Date();
req.respond(
412,
{
"Last-Modified-Version": ++lastLibraryVersion
},
""
);
}
else {
req.respond(
200,
{
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({
successful: {
"0": item.toResponseJSON()
},
unchanged: {},
failed: {}
})
);
}
return;
}
if (req.method == "GET") {
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({})
);
return;
}
});
Zotero.Sync.Data.conflictDelayIntervals = [50, 70000];
yield engine.start();
assert.equal(calls, 3);
assert.isTrue(item.synced);
assert.equal(library.libraryVersion, lastLibraryVersion);
});
})
describe("#_startDownload()", function () {
it("shouldn't redownload objects that are already up to date", 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: 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/top?format=versions&includeTrashed=1",
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 library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
// Create objects and mark them as synced
yield Zotero.SyncedSettings.set(
library.id, '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/items/top?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.isNull(Zotero.SyncedSettings.get(library.id, '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.getDateDeleted(
'setting', library.id, 'tagColors'
));
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'collection', library.id, collectionKey
));
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'search', library.id, searchKey
));
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'item', library.id, itemKey
));
})
it("should ignore remote deletions for non-item objects if local objects changed", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
// Create objects marked as unsynced
yield Zotero.SyncedSettings.set(
library.id, '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/top?format=versions&since=5&includeTrashed=1",
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(Zotero.SyncedSettings.get(library.id, 'tagColors'));
assert.ok(Zotero.Collections.exists(collectionID));
assert.ok(Zotero.Searches.exists(searchID));
})
it("should show conflict resolution window for conflicting remote deletions", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
// Create local unsynced items
var item = createUnsavedDataObject('item');
item.setField('title', 'A');
item.synced = false;
var itemID1 = yield item.saveTx({ skipSyncedUpdate: true });
var itemKey1 = item.key;
item = createUnsavedDataObject('item');
item.setField('title', 'B');
item.synced = false;
var itemID2 = yield item.saveTx({ skipSyncedUpdate: true });
var itemKey2 = 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/top?format=versions&since=5&includeTrashed=1",
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: [],
collections: [],
searches: [],
items: [itemKey1, itemKey2]
}
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// 1 (accept remote deletion)
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
mergeGroup.rightpane.click();
wizard.getButton('next').click();
// 2 (ignore remote deletion)
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield engine._startDownload();
assert.isFalse(Zotero.Items.exists(itemID1));
assert.isTrue(Zotero.Items.exists(itemID2));
})
it("should handle cancellation of conflict resolution window", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
var item = yield createDataObject('item');
var itemID = yield item.saveTx();
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/top?format=versions&since=5&includeTrashed=1",
status: 200,
headers: headers,
json: {
AAAAAAAA: 6,
[itemKey]: 6
}
});
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=AAAAAAAA%2C${itemKey}&includeTrashed=1`,
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "AAAAAAAA",
version: 6,
itemType: "book",
title: "B"
}),
makeItemJSON({
key: itemKey,
version: 6,
itemType: "book",
title: "B"
})
]
});
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: [],
collections: [],
searches: [],
items: []
}
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
wizard.getButton('cancel').click();
})
var e = yield getPromiseError(engine._startDownload());
assert.isTrue(e instanceof Zotero.Sync.UserCancelledException);
// Non-conflicted item should be saved
assert.ok(Zotero.Items.getIDFromLibraryAndKey(library.id, "AAAAAAAA"));
// Conflicted item should be skipped and in queue
assert.isFalse(Zotero.Items.exists(itemID));
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
assert.sameMembers(keys, [itemKey]);
// Library version should not have advanced
assert.equal(library.libraryVersion, 5);
});
/**
* The CR window for remote deletions is triggered separately, so test separately
*/
it("should handle cancellation of remote deletion conflict resolution window", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
// Create local unsynced items
var item = createUnsavedDataObject('item');
item.setField('title', 'A');
item.synced = false;
var itemID1 = yield item.saveTx();
var itemKey1 = item.key;
item = createUnsavedDataObject('item');
item.setField('title', 'B');
item.synced = false;
var itemID2 = yield item.saveTx();
var itemKey2 = item.key;
var headers = {
"Last-Modified-Version": 6
};
setResponse({
method: "GET",
url: "users/1/settings?since=5",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=5",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions&since=5",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items/top?format=versions&since=5&includeTrashed=1",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&since=5&includeTrashed=1",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/deleted?since=5",
status: 200,
headers,
json: {
settings: [],
collections: [],
searches: [],
items: [itemKey1, itemKey2]
}
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
wizard.getButton('cancel').click();
})
var e = yield getPromiseError(engine._startDownload());
assert.isTrue(e instanceof Zotero.Sync.UserCancelledException);
// Conflicted items should still exists
assert.isTrue(Zotero.Items.exists(itemID1));
assert.isTrue(Zotero.Items.exists(itemID2));
// Library version should not have advanced
assert.equal(library.libraryVersion, 5);
});
it("should restart if remote library version changes", function* () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
yield library.saveTx();
({ engine, client, caller } = yield setup());
var lastLibraryVersion = 5;
var calls = 0;
var t;
server.respond(function (req) {
if (req.url.startsWith(baseURL + "users/1/settings")) {
calls++;
if (calls == 2) {
assert.isAbove(new Date() - t, 50);
}
t = new Date();
req.respond(
200,
{
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({})
);
return;
}
else if (req.url.startsWith(baseURL + "users/1/searches")) {
if (calls == 1) {
t = new Date();
req.respond(
200,
{
// On the first pass, return a later library version to simulate data
// being updated by a concurrent upload
"Last-Modified-Version": lastLibraryVersion + 1
},
JSON.stringify([])
);
return;
}
}
else if (req.url.startsWith(baseURL + "users/1/items")) {
// Since /searches is called before /items and it should cause a reset,
// /items shouldn't be called until the second pass
if (calls < 1) {
throw new Error("/users/1/items called in first pass");
}
}
t = new Date();
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify([])
);
});
Zotero.Sync.Data.conflictDelayIntervals = [50, 70000];
yield engine._startDownload();
assert.equal(calls, 2);
assert.equal(library.libraryVersion, lastLibraryVersion);
});
});
describe("#_downloadUpdatedObjects()", function () {
it("should include objects in sync queue", function* () {
({ engine, client, caller } = yield setup());
var libraryID = Zotero.Libraries.userLibraryID;
var objectType = 'collection';
var objectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(
objectType, libraryID, ["BBBBBBBB", "CCCCCCCC"]
);
yield Zotero.DB.queryAsync(
"UPDATE syncQueue SET lastCheck=lastCheck-3600 "
+ "WHERE syncObjectTypeID=? AND libraryID=? AND key IN (?, ?)",
[objectTypeID, libraryID, 'BBBBBBBB', 'CCCCCCCC']
);
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=1",
status: 200,
headers,
json: {
AAAAAAAA: 5,
BBBBBBBB: 5
}
});
var stub = sinon.stub(engine, "_downloadObjects");
yield engine._downloadUpdatedObjects(objectType, 1, 5);
assert.ok(stub.calledWith("collection", ["AAAAAAAA", "BBBBBBBB", "CCCCCCCC"]));
stub.restore();
});
});
describe("#_downloadObjects()", function () {
it("should remove object from sync queue if missing from response", function* () {
({ engine, client, caller } = yield setup({
stopOnError: false
}));
var libraryID = Zotero.Libraries.userLibraryID;
var objectType = 'collection';
var objectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(
objectType, libraryID, ["BBBBBBBB", "CCCCCCCC"]
);
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: "users/1/collections?format=json&collectionKey=AAAAAAAA%2CBBBBBBBB%2CCCCCCCCC",
status: 200,
headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 5,
name: "A"
}),
makeCollectionJSON({
key: "BBBBBBBB",
version: 5
// Missing 'name', which causes a save error
})
]
});
yield engine._downloadObjects(objectType, ["AAAAAAAA", "BBBBBBBB", "CCCCCCCC"]);
// Missing object should have been removed, but invalid object should remain
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
assert.sameMembers(keys, ['BBBBBBBB']);
});
});
describe("#_startUpload()", function () {
it("shouldn't upload unsynced objects if present in sync queue", function* () {
({ engine, client, caller } = yield setup());
var libraryID = Zotero.Libraries.userLibraryID;
var objectType = 'item';
var obj = yield createDataObject(objectType);
yield Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, libraryID, [obj.key]);
var result = yield engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_NOTHING_TO_UPLOAD);
});
it("should prompt to reset library on 403 write response and reset on accept", function* () {
var group = yield createGroup({
libraryVersion: 5
});
var libraryID = group.libraryID;
({ engine, client, caller } = yield setup({ libraryID }));
var item = createUnsavedDataObject('item');
item.libraryID = libraryID;
item.setField('title', 'A');
item.synced = false;
var itemID = yield item.saveTx();
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "POST",
url: `groups/${group.id}/items`,
status: 403,
headers,
text: ""
})
var promise = waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
assert.include(text, group.name);
});
var result = yield engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_RESTART);
assert.isFalse(Zotero.Items.exists(itemID));
// Library version should have been reset to trigger full sync
assert.equal(group.libraryVersion, -1);
});
it("should prompt to reset library on 403 write response and skip on cancel", function* () {
var group = yield createGroup({
libraryVersion: 5
});
var libraryID = group.libraryID;
({ engine, client, caller } = yield setup({ libraryID }));
var item = createUnsavedDataObject('item');
item.libraryID = libraryID;
item.setField('title', 'A');
item.synced = false;
var itemID = yield item.saveTx();
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "POST",
url: `groups/${group.id}/items`,
status: 403,
headers,
text: ""
})
var promise = waitForDialog(function (dialog) {
var text = dialog.document.documentElement.textContent;
assert.include(text, group.name);
}, "cancel");
var result = yield engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_CANCEL);
assert.isTrue(Zotero.Items.exists(itemID));
// Library version shouldn't have changed
assert.equal(group.libraryVersion, 5);
});
it("should trigger full sync on object conflict", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var item = createUnsavedDataObject('item');
item.version = lastLibraryVersion;
yield item.saveTx();
setResponse({
method: "POST",
url: "users/1/items",
status: 200,
headers: {
"Last-Modified-Version": lastLibraryVersion
},
json: {
successful: {},
unchanged: {},
failed: {
"0": {
"code": 412,
"message": `Item doesn't exist (expected version ${lastLibraryVersion}; `
+ "use 0 instead)"
}
}
}
});
var result = yield engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_OBJECT_CONFLICT);
});
// Note: This shouldn't be necessary, since collections are sorted top-down before uploading
it("should mark local collection as unsynced if it doesn't exist when uploading collection", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var collection1 = createUnsavedDataObject('collection');
// Set the collection as synced (though this shouldn't happen)
collection1.synced = true;
yield collection1.saveTx();
var collection2 = yield createDataObject('collection', { collections: [collection1.id] });
var called = 0;
server.respond(function (req) {
let requestJSON = JSON.parse(req.requestBody);
if (called == 0) {
assert.lengthOf(requestJSON, 1);
assert.equal(requestJSON[0].key, collection2.key);
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {},
unchanged: {},
failed: {
0: {
code: 409,
message: `Parent collection ${collection1.key} doesn't exist`,
data: {
collection: collection1.key
}
}
}
})
);
}
called++;
});
var e = yield getPromiseError(engine._startUpload());
assert.ok(e);
assert.isFalse(collection1.synced);
});
it("should mark local collection as unsynced if it doesn't exist when uploading item", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var collection = createUnsavedDataObject('collection');
// Set the collection as synced (though this shouldn't happen)
collection.synced = true;
yield collection.saveTx();
var item = yield createDataObject('item', { collections: [collection.id] });
var called = 0;
server.respond(function (req) {
let requestJSON = JSON.parse(req.requestBody);
if (called == 0) {
assert.lengthOf(requestJSON, 1);
assert.equal(requestJSON[0].key, item.key);
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {},
unchanged: {},
failed: {
0: {
code: 409,
message: `Collection ${collection.key} doesn't exist`,
data: {
collection: collection.key
}
}
}
})
);
}
called++;
});
var e = yield getPromiseError(engine._startUpload());
assert.ok(e);
assert.isFalse(collection.synced);
});
it("should mark local parent item as unsynced if it doesn't exist when uploading child", function* () {
({ engine, client, caller } = yield setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
yield library.saveTx();
var item = createUnsavedDataObject('item');
// Set the parent item as synced (though this shouldn't happen)
item.synced = true;
yield item.saveTx();
var note = yield createDataObject('item', { itemType: 'note', parentID: item.id });
var called = 0;
server.respond(function (req) {
let requestJSON = JSON.parse(req.requestBody);
if (called == 0) {
assert.lengthOf(requestJSON, 1);
assert.equal(requestJSON[0].key, note.key);
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {},
unchanged: {},
failed: {
0: {
code: 409,
message: `Parent item ${item.key} doesn't exist`,
data: {
parentItem: item.key
}
}
}
})
);
}
else if (called == 1) {
assert.lengthOf(requestJSON, 2);
assert.sameMembers(requestJSON.map(o => o.key), [item.key, note.key]);
req.respond(
200,
{
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({
successful: {
0: item.toResponseJSON(),
1: note.toResponseJSON()
},
unchanged: {},
failed: {}
})
);
}
called++;
});
var result = yield engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_SUCCESS);
assert.equal(called, 2);
});
});
describe("Conflict Resolution", function () {
beforeEach(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
after(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
it("should show conflict resolution window on item conflicts", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objects = [];
var values = [];
var dateAdded = Date.now() - 86400000;
var responseJSON = [];
for (let i = 0; i < 2; i++) {
values.push({
left: {},
right: {}
});
// Create local object
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Save original version in cache
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create updated JSON for download
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
}
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('next').click();
// 2 (local)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
// Select local object
mergeGroup.leftpane.click();
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', objects.map(o => o.key));
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('version'), values[1].left.version);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should show conflict resolution window on note conflicts", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objects = [];
var values = [];
var dateAdded = Date.now() - 86400000;
var responseJSON = [];
for (let i = 0; i < 2; i++) {
values.push({
left: {},
right: {}
});
// Create local object
let obj = objects[i] = new Zotero.Item('note');
obj.setNote(Zotero.Utilities.randomString());
obj.version = 10;
obj.dateAdded = Zotero.Date.dateToSQL(new Date(dateAdded), true);
// Set Date Modified values one minute apart to enforce order
obj.dateModified = Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
);
yield obj.saveTx();
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Save original version in cache
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create updated JSON for download
values[i].right.note = jsonData.note = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
obj.setNote(Zotero.Utilities.randomString());
yield obj.saveTx({
skipDateModifiedUpdate: true
});
values[i].left.note = obj.getNote();
values[i].left.version = obj.getField('version');
}
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('next').click();
// 2 (local)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
// Select local object
mergeGroup.leftpane.click();
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
});
yield engine._downloadObjects('item', objects.map(o => o.key));
assert.equal(objects[0].getNote(), values[0].right.note);
assert.equal(objects[1].getNote(), values[1].left.note);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('version'), values[1].left.version);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should resolve all remaining conflicts with one side", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objects = [];
var values = [];
var responseJSON = [];
var dateAdded = Date.now() - 86400000;
for (let i = 0; i < 3; i++) {
values.push({
left: {},
right: {}
});
// Create object in cache
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Save original version in cache
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.getField('version');
}
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
var resolveAll = doc.getElementById('resolve-all');
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllRemoteFields')
);
wizard.getButton('next').click();
// 2 (local and Resolve All checkbox)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
mergeGroup.leftpane.click();
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllLocalFields')
);
resolveAll.click();
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', objects.map(o => o.key));
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[0].getField('version'), values[0].right.version);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[1].getField('version'), values[1].left.version);
assert.equal(objects[2].getField('title'), values[2].left.title);
assert.equal(objects[2].getField('version'), values[2].left.version);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
// Note: Conflicts with remote deletions are handled in _startDownload()
it("should handle local item deletion, keeping deletion", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Delete object locally
yield obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var windowOpened = false;
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
windowOpened = true;
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftpane.pane.onclick);
// Select local deleted version
mergeGroup.leftpane.pane.click();
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', [obj.key]);
assert.isTrue(windowOpened);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
it("should handle local child note deletion, keeping deletion", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var responseJSON = [];
var parent = yield createDataObject('item');
// Create object, generate JSON, and delete
var obj = new Zotero.Item('note');
obj.parentItemID = parent.id;
obj.setNote(Zotero.Utilities.randomString());
obj.version = 10;
yield obj.saveTx();
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
// Delete object locally
yield obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.note = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${obj.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var windowOpened = false;
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
windowOpened = true;
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftpane.pane.onclick);
// Select local deleted version
mergeGroup.leftpane.pane.click();
wizard.getButton('finish').click();
});
yield engine._downloadObjects('item', [obj.key]);
assert.isTrue(windowOpened);
obj = Zotero.Items.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should restore locally deleted item", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
assert.isTrue(doc.getElementById('resolve-all').hidden);
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', [key]);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getField('title'), jsonData.title);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should handle local deletion and remote move to trash", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = yield createDataObject(type, { version: 10 });
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.deleted = true;
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
yield engine._downloadObjects('item', [key]);
assert.isFalse(objectsClass.exists(libraryID, key));
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
// Deletion should still be in sync delete log for uploading
assert.ok(yield Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, key));
});
it("should handle remote move to trash and local deletion", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create trashed object
var obj = createUnsavedDataObject(type);
obj.deleted = true;
yield obj.saveTx();
setResponse({
method: "GET",
url: `users/1/deleted?since=10`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: {
collections: [],
searches: [],
items: [obj.key],
}
});
yield engine._downloadDeletions(10, 15);
// Local object should have been deleted
assert.isFalse(objectsClass.exists(libraryID, obj.key));
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
// Deletion shouldn't be in sync delete log
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, obj.key));
});
it("should handle note conflict", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
var noteText1 = "<p>A</p>";
var noteText2 = "<p>B</p>";
// Create object in cache
var obj = new Zotero.Item('note');
obj.setNote("");
obj.version = 10;
yield obj.saveTx();
var jsonData = obj.toJSON();
var key = jsonData.key = obj.key;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
json.data.note = noteText2;
responseJSON.push(json);
// Modify local version
obj.setNote(noteText1);
setResponse({
method: "GET",
url: `users/1/items?format=json&itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield engine._downloadObjects('item', [key]);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getNote(), noteText2);
var keys = yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
});
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(type, userLibraryID);
assert.deepEqual(synced, [objects[type][0].key]);
assert.equal(objects[type][0].version, 10);
var unsynced = yield Zotero.Sync.Data.Local.getUnsynced(type, userLibraryID);
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());
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
for (let type of types) {
// Create object with outdated version, 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 object that exists remotely and not locally,
// which should be downloaded
objectJSON[type].push(makeJSONFunctions[type]({
key: Zotero.DataObjectUtilities.generateKey(),
version: 20,
name: Zotero.Utilities.randomString()
}));
// Create object marked as synced that doesn'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);
// Create object marked as synced that doesn't exist remotely but is in the
// remote delete log, which should be deleted locally
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
}
}
});
let deletedJSON = {};
for (let type of types) {
let suffix = type == 'item' ? '&includeTrashed=1' : '';
let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
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/" + plural
+ "?format=versions" + suffix,
status: 200,
headers: headers,
json: json
});
setResponse({
method: "GET",
url: "users/1/" + plural
+ "?format=json"
+ "&" + type + "Key=" + objectJSON[type][0].key + "%2C" + objectJSON[type][1].key
+ suffix,
status: 200,
headers: headers,
json: objectJSON[type]
});
deletedJSON[plural] = [objects[type][2].key];
}
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: deletedJSON
});
yield engine._fullSync();
// Check settings
var setting = Zotero.SyncedSettings.get(userLibraryID, "tagColors");
assert.lengthOf(setting, 1);
assert.equal(setting[0].name, 'A');
var settingMetadata = 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);
yield assertInCache(obj);
// 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);
// JSON objects 3 should be deleted and not in the delete log
assert.isFalse(objectsClass.getByLibraryAndKey(userLibraryID, objects[type][2].key));
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
type, userLibraryID, objects[type][2].key
));
}
});
it("should reprocess remote deletions", function* () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = yield setup());
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectIDs = {};
for (let type of types) {
// Create object marked as synced that's in the remote delete log, which should be
// deleted locally
let obj = createUnsavedDataObject(type);
obj.synced = true;
obj.version = 5;
yield obj.saveTx();
objects[type] = [obj];
objectIDs[type] = [obj.id];
// Create object marked as unsynced that's in the remote delete log, which should
// trigger a conflict in the case of items and otherwise reset version to 0
obj = createUnsavedDataObject(type);
obj.synced = false;
obj.version = 5;
yield obj.saveTx();
objects[type].push(obj);
objectIDs[type].push(obj.id);
}
var headers = {
"Last-Modified-Version": 20
}
setResponse({
method: "GET",
url: "users/1/settings",
status: 200,
headers,
json: {}
});
let deletedJSON = {};
for (let type of types) {
let suffix = type == 'item' ? '&includeTrashed=1' : '';
let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
setResponse({
method: "GET",
url: "users/1/" + plural + "?format=versions" + suffix,
status: 200,
headers,
json: {}
});
deletedJSON[plural] = objects[type].map(o => o.key);
}
setResponse({
method: "GET",
url: "users/1/deleted?since=0",
status: 200,
headers: headers,
json: deletedJSON
});
// Apply remote deletions
var shown = false;
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
shown = true;
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Should be one conflict for each object type; select local
var numConflicts = Object.keys(objects).length;
for (let i = 0; i < numConflicts; i++) {
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
if (i < numConflicts - 1) {
wizard.getButton('next').click();
}
else {
wizard.getButton('finish').click();
}
}
});
yield engine._fullSync();
assert.ok(shown);
// Check objects
for (let type of types) {
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
// Objects 0 should be deleted
assert.isFalse(objectsClass.exists(objectIDs[type][0]));
// Objects 1 should be marked for reupload
assert.isTrue(objectsClass.exists(objectIDs[type][1]));
assert.strictEqual(objects[type][1].version, 0);
assert.strictEqual(objects[type][1].synced, false);
}
});
})
})