zotero/test/tests/syncEngineTest.js
Dan Stillman 20735c425f
Add resetData() as lightweight alternative to resetDB() for sync tests (#5822)
resetData() clears user data and sync state using application APIs
instead of doing a full Zotero shutdown/reinit cycle, making it much
faster for tests that just need clean state between runs.

Also make Tags.init(), Creators.init(), Users.init(), and
SyncedSettings.loadAll() properly clear their caches before reloading,
so they are safe to call more than once.
2026-03-05 15:54:10 -05:00

5067 lines
141 KiB
JavaScript

"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 userID = 1;
var responses = {};
var setup = async function (options = {}) {
server = sinon.fakeServer.create();
server.respondImmediately = true;
var background = options.background === undefined ? true : options.background;
var stopOnError = options.stopOnError === undefined ? true : options.stopOnError;
const { ConcurrentCaller } = ChromeUtils.importESModule("resource://zotero/concurrentCaller.mjs");
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({
userID,
apiClient: client,
libraryID: options.libraryID || Zotero.Libraries.userLibraryID,
stopOnError
});
return { engine, client, caller };
};
function setResponse(response) {
setHTTPResponse(server, baseURL, response, responses);
}
function setDefaultResponses(options = {}) {
var target = options.target || 'users/1';
var headers = {
"Last-Modified-Version": options.libraryVersion || 5
};
var lastLibraryVersion = options.lastLibraryVersion || 4;
setResponse({
method: "GET",
url: `${target}/settings?since=${lastLibraryVersion}`,
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: `${target}/collections?format=versions&since=${lastLibraryVersion}`,
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: `${target}/searches?format=versions&since=${lastLibraryVersion}`,
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: `${target}/items/top?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: `${target}/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: `${target}/deleted?since=${lastLibraryVersion}`,
status: 200,
headers,
json: {}
});
}
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 = async function (obj) {
var cacheObject = await Zotero.Sync.Data.Local.getCacheObject(
obj.objectType, obj.libraryID, obj.key, obj.version
);
assert.isObject(cacheObject);
assert.propertyVal(cacheObject, 'key', obj.key);
};
var assertNotInCache = async function (obj) {
assert.isFalse(await Zotero.Sync.Data.Local.getCacheObject(
obj.objectType, obj.libraryID, obj.key, obj.version
));
};
//
// Tests
//
beforeEach(async function () {
await resetData();
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
await Zotero.Users.setCurrentUserID(userID);
await Zotero.Users.setCurrentUsername("testuser");
await Zotero.Users.setCurrentName("Test User");
})
after(function () {
Zotero.HTTP.mock = null;
});
describe("Syncing", function () {
it("should download items into a new library", async function () {
({ engine, client, caller } = await 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?collectionKey=AAAAAAAA",
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/searches?searchKey=AAAAAAAA",
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "AAAAAAAA",
version: 2,
name: "A"
})
]
});
setResponse({
method: "GET",
url: "users/1/items?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?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: {}
});
await 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 = await Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 1);
assert.isTrue(obj.synced);
await assertInCache(obj);
obj = await Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 2);
assert.isTrue(obj.synced);
await assertInCache(obj);
obj = await Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA");
assert.equal(obj.getField('title'), 'A');
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
var parentItemID = obj.id;
await assertInCache(obj);
obj = await Zotero.Items.getByLibraryAndKeyAsync(userLibraryID, "BBBBBBBB");
assert.equal(obj.note, 'This is a note.');
assert.equal(obj.parentItemID, parentItemID);
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
await assertInCache(obj);
})
it("should download items into a new read-only group", async function () {
var group = await createGroup({
editable: false,
filesEditable: false
});
var libraryID = group.libraryID;
var itemToDelete = await createDataObject(
'item', { libraryID, synced: true }, { skipEditCheck: true }
)
var itemToDeleteID = itemToDelete.id;
({ engine, client, caller } = await 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?collectionKey=AAAAAAAA`,
status: 200,
headers: headers,
json: [
makeCollectionJSON({
key: "AAAAAAAA",
version: 1,
name: "A"
})
]
});
setResponse({
method: "GET",
url: `groups/${group.id}/searches?searchKey=AAAAAAAA`,
status: 200,
headers: headers,
json: [
makeSearchJSON({
key: "AAAAAAAA",
version: 2,
name: "A"
})
]
});
setResponse({
method: "GET",
url: `groups/${group.id}/items?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?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]
}
});
await 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);
await assertInCache(obj);
obj = Zotero.Searches.getByLibraryAndKey(libraryID, "AAAAAAAA");
assert.equal(obj.name, 'A');
assert.equal(obj.version, 2);
assert.isTrue(obj.synced);
await 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;
await assertInCache(obj);
obj = Zotero.Items.getByLibraryAndKey(libraryID, "BBBBBBBB");
assert.equal(obj.note, 'This is a note.');
assert.equal(obj.parentItemID, parentItemID);
assert.equal(obj.version, 3);
assert.isTrue(obj.synced);
await assertInCache(obj);
assert.isFalse(Zotero.Items.exists(itemToDeleteID));
});
it("should upload new full items and subsequent patches", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = library.storageVersion = lastLibraryVersion;
await library.saveTx();
await Zotero.SyncedSettings.set(libraryID, "testSetting1", { foo: "bar" });
await 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] = [await 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;
}
}
}
})
await engine.start();
await Zotero.SyncedSettings.set(libraryID, "testSetting2", { bar: "bar" });
assert.equal(library.libraryVersion, lastLibraryVersion);
assert.equal(library.storageVersion, lastLibraryVersion);
for (let type of types) {
// Make sure objects were set to the correct version and marked as synced
assert.lengthOf(((await 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 = await Zotero.Sync.Data.Local.getCacheObject(type, libraryID, key, version);
assert.typeOf(cached, 'object');
assert.equal(cached.key, key);
assert.equal(cached.version, version);
await modifyDataObject(objects[type][0]);
}
({ engine, client, caller } = await 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;
}
}
}
})
await engine.start();
assert.equal(library.libraryVersion, lastLibraryVersion);
assert.equal(library.storageVersion, lastLibraryVersion);
for (let type of types) {
// Make sure objects were set to the correct version and marked as synced
assert.lengthOf(((await 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 = await 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 = await Zotero.Sync.Data.Local.getCacheObjectVersions(type, libraryID, key);
assert.sameMembers(versions, [version]);
}
})
it("should upload child items after parent items", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
// Create top-level note, embedded-image attachment, book, and child note
var note1 = await createDataObject('item', { itemType: 'note', note: 'A' });
var attachment = await Zotero.Attachments.importEmbeddedImage({
blob: await File.createFromFileName(
OS.Path.join(getTestDataDirectory().path, 'test.png')
),
parentItemID: note1.id
});
var item = await createDataObject('item');
var note2 = await createDataObject('item', { itemType: 'note', parentID: item.id, note: 'B' });
// Move note under parent
note1.parentItemID = item.id;
await note1.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, 4);
assert.equal(json[0].key, item.key);
assert.oneOf(
[json[1].key, json[2].key, json[3].key].join(''),
[
[note1.key, attachment.key, note2.key].join(''),
[note2.key, note1.key, attachment.key].join(''),
]
);
let successful;
if (json[1].key == note1.key) {
successful = {
"0": item.toResponseJSON({ version: lastLibraryVersion }),
"1": note1.toResponseJSON({ version: lastLibraryVersion }),
"2": attachment.toResponseJSON({ version: lastLibraryVersion }),
"3": note2.toResponseJSON({ version: lastLibraryVersion }),
};
}
else {
successful = {
"0": item.toResponseJSON({ version: lastLibraryVersion }),
"1": note2.toResponseJSON({ version: lastLibraryVersion }),
"2": note1.toResponseJSON({ version: lastLibraryVersion }),
"3": attachment.toResponseJSON({ version: lastLibraryVersion }),
};
}
handled = true;
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({
successful,
unchanged: {},
failed: {}
})
);
return;
}
});
await engine.start();
assert.isTrue(handled);
});
it("shouldn't update existing cache object after upload on 'unchanged' response", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var item = await createDataObject('item', { version: 1, title: "A" });
var json = item.toJSON();
// Save current version to cache so the patch object is empty, as if the item had been
// added to a collection and removed from it (such that even dateModified didn't change)
await Zotero.Sync.Data.Local.saveCacheObjects('item', library.id, [json]);
server.respond(function (req) {
if (req.method == "POST" && req.url == baseURL + "users/1/items") {
let json = JSON.parse(req.requestBody);
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": ++lastLibraryVersion
},
JSON.stringify({
successful: {},
unchanged: {
"0": item.key
},
failed: {}
})
);
return;
}
});
await engine.start();
// Check data in cache
var version = await Zotero.Sync.Data.Local.getLatestCacheObjectVersion(
'item', library.id, item.key
);
assert.equal(version, 1);
json = await Zotero.Sync.Data.Local.getCacheObject(
'item', library.id, item.key, 1
);
assert.propertyVal(json.data, 'itemType', 'book');
assert.propertyVal(json.data, 'title', 'A');
});
it("should upload child collection after parent collection", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
var collection3 = await createDataObject('collection', { parentID: collection2.id });
// Move collection under the other
collection1.parentID = collection2.id;
await 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;
}
});
await engine.start();
assert.isTrue(handled);
});
it("should update library version after settings upload", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = library.storageVersion = lastLibraryVersion;
await library.saveTx();
await 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;
}
}
})
await engine.start();
assert.isAbove(library.libraryVersion, 5);
assert.equal(library.libraryVersion, lastLibraryVersion);
assert.equal(library.storageVersion, lastLibraryVersion);
});
it("should upload settings in batches", async function () {
({ engine, client, caller } = await setup());
var batchSize = 250;
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = library.storageVersion = lastLibraryVersion;
await library.saveTx();
for (let i = 0; i < (batchSize * 2 + 5); i++) {
let key = Zotero.DataObjectUtilities.generateKey();
let page = Zotero.Utilities.rand(1, 20);
await Zotero.SyncedSettings.set(libraryID, `lastPageIndex_u_${key}`, page);
}
var keys = new Set();
var numRequests = 0;
server.respond(function (req) {
if (req.method == "POST") {
numRequests++;
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
if (req.url == baseURL + "users/1/settings") {
let json = JSON.parse(req.requestBody);
Zotero.debug(json);
if (numRequests == 1 || numRequests == 2) {
assert.lengthOf(Object.keys(json), batchSize);
}
else {
assert.lengthOf(Object.keys(json), 5);
}
for (let key of Object.keys(json)) {
keys.add(key);
}
req.respond(
204,
{
"Last-Modified-Version": ++lastLibraryVersion
},
""
);
return;
}
}
})
await engine.start();
assert.equal(numRequests, 3);
assert.equal(keys.size, batchSize * 2 + 5);
});
it("shouldn't update library storage version after settings upload if storage version was already behind", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
library.storageVersion = 4;
await library.saveTx();
await 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;
}
}
})
await engine.start();
assert.isAbove(library.libraryVersion, 5);
assert.equal(library.libraryVersion, lastLibraryVersion);
assert.equal(library.storageVersion, 4);
});
it("shouldn't update library storage version after item upload if storage version was already behind", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
library.storageVersion = 4;
await library.saveTx();
var item = await createDataObject('item');
var itemResponseJSON = item.toResponseJSON();
server.respond(function (req) {
if (req.method == "POST") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
if (req.url == baseURL + "users/1/items") {
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": itemResponseJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
await engine.start();
assert.equal(library.libraryVersion, lastLibraryVersion);
assert.equal(library.storageVersion, 4);
});
it("should process downloads after upload failure", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var collection = await createDataObject('collection');
var called = 0;
server.respond(function (req) {
if (called == 0) {
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {},
unchanged: {},
failed: {
0: {
code: 400,
message: "Upload failed"
}
}
})
);
}
called++;
});
var stub = sinon.stub(engine, "_startDownload")
.returns(Promise.resolve(engine.DOWNLOAD_RESULT_CONTINUE));
var e = await getPromiseError(engine.start());
assert.equal(called, 1);
// start() should still fail
assert.ok(e);
assert.equal(e.message, "Made no progress during upload -- stopping");
// The collection shouldn't have been marked as synced
assert.isFalse(collection.synced);
// Download should have been performed
assert.ok(stub.called);
stub.restore();
});
it("shouldn't update library storage version if there were storage metadata changes", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
library.storageVersion = lastLibraryVersion;
await library.saveTx();
var target = 'users/1';
var newLibraryVersion = 5;
var headers = {
"Last-Modified-Version": newLibraryVersion
};
// Create an attachment response with storage metadata
var item = new Zotero.Item('attachment');
item.libraryID = Zotero.Libraries.userLibraryID;
item.attachmentLinkMode = 'imported_file';
item.attachmentFilename = 'test.txt';
item.attachmentContentType = 'text/plain';
item.attachmentCharset = 'utf-8';
var itemResponseJSON = item.toResponseJSON();
itemResponseJSON.key = itemResponseJSON.data.key = Zotero.DataObjectUtilities.generateKey();
itemResponseJSON.version = itemResponseJSON.data.version = newLibraryVersion;
itemResponseJSON.data.mtime = new Date().getTime();
itemResponseJSON.data.md5 = '57f8a4fda823187b91e1191487b87fe6';
setDefaultResponses({
target,
lastLibraryVersion: lastLibraryVersion,
libraryVersion: newLibraryVersion
});
setResponse({
method: "GET",
url: `${target}/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
status: 200,
headers,
json: {
[item.key]: newLibraryVersion
}
});
setResponse({
method: "GET",
url: `${target}/items?itemKey=${item.key}&includeTrashed=1`,
status: 200,
headers,
json: [itemResponseJSON]
});
await engine.start();
assert.equal(library.libraryVersion, newLibraryVersion);
assert.equal(library.storageVersion, lastLibraryVersion);
});
it("should update library storage version if there were no storage metadata changes and storage version wasn't already behind", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
library.storageVersion = lastLibraryVersion;
await library.saveTx();
var target = 'users/1';
var newLibraryVersion = 5;
var headers = {
"Last-Modified-Version": newLibraryVersion
};
setDefaultResponses({
target,
lastLibraryVersion: lastLibraryVersion,
libraryVersion: newLibraryVersion
});
await engine.start();
assert.equal(library.libraryVersion, newLibraryVersion);
assert.equal(library.storageVersion, newLibraryVersion);
});
it("shouldn't update library storage version if there were no storage metadata changes but storage version was already behind", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
library.storageVersion = 1;
await library.saveTx();
var target = 'users/1';
var newLibraryVersion = 5;
var headers = {
"Last-Modified-Version": newLibraryVersion
};
setDefaultResponses({
target,
lastLibraryVersion: lastLibraryVersion,
libraryVersion: newLibraryVersion
});
await engine.start();
assert.equal(library.libraryVersion, newLibraryVersion);
assert.equal(library.storageVersion, 1);
});
it("shouldn't include mtime and md5 for attachments in ZFS libraries", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var item = new Zotero.Item('attachment');
item.attachmentLinkMode = 'imported_file';
item.attachmentFilename = 'test.txt';
item.attachmentContentType = 'text/plain';
item.attachmentCharset = 'utf-8';
await 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;
}
}
})
await engine.start();
});
it("should include storage properties for attachments in WebDAV libraries", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
await 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';
await 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.notPropertyVal(itemJSON, "mtime");
assert.notPropertyVal(itemJSON, "md5");
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({
successful: {
"0": itemResponseJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
await engine.start();
});
it("should include mtime and md5 synced to WebDAV in WebDAV libraries", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 2;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
Zotero.Sync.Storage.Local.setModeForLibrary(library.id, 'webdav');
var item = new Zotero.Item('attachment');
item.attachmentLinkMode = 'imported_file';
item.attachmentFilename = 'test1.txt';
await item.saveTx();
var mtime = new Date().getTime();
var md5 = '57f8a4fda823187b91e1191487b87fe6';
item.attachmentSyncedModificationTime = mtime;
item.attachmentSyncedHash = md5;
await 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;
}
}
})
await engine.start();
// Check data in cache
var json = await Zotero.Sync.Data.Local.getCacheObject(
'item', library.id, item.key, lastLibraryVersion
);
assert.equal(json.data.mtime, mtime);
assert.equal(json.data.md5, md5);
})
// See also: "shouldn't include external annotations" in syncLocalTest.js
it("shouldn't upload external annotations", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var nextLibraryVersion = lastLibraryVersion + 1;
var attachment = await importFileAttachment('test.pdf');
var annotation1 = await createAnnotation('highlight', attachment);
var annotation2 = await createAnnotation('highlight', attachment, { isExternal: true });
var item1ResponseJSON = attachment.toResponseJSON();
item1ResponseJSON.version = item1ResponseJSON.data.version = nextLibraryVersion;
var item2ResponseJSON = annotation1.toResponseJSON();
item2ResponseJSON.version = item2ResponseJSON.data.version = nextLibraryVersion;
server.respond(function (req) {
if (req.method == "POST") {
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"], lastLibraryVersion
);
if (req.url == baseURL + "users/1/items") {
let json = JSON.parse(req.requestBody);
assert.lengthOf(json, 2);
let keys = [json[0].key, json[1].key];
assert.include(keys, attachment.key);
assert.include(keys, annotation1.key);
req.respond(
200,
{
"Content-Type": "application/json",
"Last-Modified-Version": nextLibraryVersion
},
JSON.stringify({
successful: {
"0": item1ResponseJSON,
"1": item2ResponseJSON
},
unchanged: {},
failed: {}
})
);
return;
}
}
})
await engine.start();
});
it("should update local objects with remotely saved version after uploading if necessary", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectResponseJSON = {};
var objectNames = {};
var itemDateModified = {};
for (let type of types) {
objects[type] = [
await 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;
}
}
}
})
await 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(((await 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", async function () {
var { engine, client, caller } = await setup();
var library = Zotero.Libraries.userLibrary;
var lastLibraryVersion = 5;
library.libraryVersion = library.storageVersion = lastLibraryVersion;
await library.saveTx();
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
for (let type of types) {
let obj1 = await createDataObject(type);
let obj2 = await createDataObject(type);
objects[type] = [obj1.key, obj2.key];
await obj1.eraseTx();
await 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;
}
}
}
})
await engine.start();
assert.equal(count, 0);
for (let type of types) {
assert.lengthOf(
await Zotero.Sync.Data.Local.getDeleted(type, library.id), 0
);
}
assert.equal(library.libraryVersion, lastLibraryVersion);
assert.equal(library.storageVersion, lastLibraryVersion);
})
it("should make only one request if in sync", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await 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;
}
}
});
await engine.start();
})
it("should add objects to sync queue if they can't be saved", async function () {
({ engine, client, caller } = await 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?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 -- collection should be queued
unknownField: 5
})
]
});
setResponse({
method: "GET",
url: "users/1/searches?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?itemKey=GGGGGGGG%2CHHHHHHHH&includeTrashed=1",
status: 200,
headers: headers,
json: [
makeItemJSON({
key: "GGGGGGGG",
version: 3,
itemType: "book",
title: "G",
// Unknown item field -- item should be queued
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?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");
await engine.start();
var userLibraryID = Zotero.Libraries.userLibraryID;
// Library version should have been updated
assert.equal(Zotero.Libraries.getVersion(userLibraryID), 3);
// Check for saved objects
assert.ok(await Zotero.Collections.getByLibraryAndKeyAsync(userLibraryID, "AAAAAAAA"));
assert.ok(await Zotero.Searches.getByLibraryAndKeyAsync(userLibraryID, "DDDDDDDD"));
// Check for queued objects
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', userLibraryID);
assert.sameMembers(keys, ['BBBBBBBB']);
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('search', userLibraryID);
assert.sameMembers(keys, ['EEEEEEEE', 'FFFFFFFF']);
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', userLibraryID);
assert.sameMembers(keys, ['GGGGGGGG', 'HHHHHHHH', 'JJJJJJJJ']);
// Unknown search condition, search operator, item field, and item type
//
// Missing parent collection and item collection don't throw errors because they don't
// indicate a guaranteed problem with the client
assert.equal(spy.callCount, 4);
});
it("shouldn't show CR window if remote data contains unknown field", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 1;
await library.saveTx();
var item = createUnsavedDataObject('item', { title: 'a' });
item.version = 1;
await item.saveTx();
var json = item.toResponseJSON();
json.data.title = 'b';
json.data.invalidField = 'abcd';
var headers = {
"Last-Modified-Version": 2
};
server.respond(function (req) {
// Return 412, because item has changed remotely
if (req.method == "POST") {
if (!req.url.startsWith(baseURL + "users/1/items")) {
throw new Error("Unexpected POST");
}
req.respond(
412,
{
"Last-Modified-Version": 2
},
""
);
}
});
setResponse({
method: "GET",
url: "users/1/settings?since=1",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/collections?format=versions&since=1",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/searches?format=versions&since=1",
status: 200,
headers,
json: {}
});
setResponse({
method: "GET",
url: "users/1/items/top?format=versions&since=1&includeTrashed=1",
status: 200,
headers,
json: {
[item.key]: 2
}
});
setResponse({
method: "GET",
url: "users/1/items?format=versions&since=1&includeTrashed=1",
status: 200,
headers,
json: {
[item.key]: 2
}
});
setResponse({
method: "GET",
url: `users/1/items?itemKey=${item.key}&includeTrashed=1`,
status: 200,
headers,
json: [json]
});
setResponse({
method: "GET",
url: "users/1/deleted?since=1",
status: 200,
headers,
json: {}
});
var spy = sinon.spy(engine, "onError");
await engine.start();
var userLibraryID = Zotero.Libraries.userLibraryID;
// Library version should have been updated
assert.equal(Zotero.Libraries.getVersion(userLibraryID), 2);
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', userLibraryID);
assert.sameMembers(keys, [item.key]);
});
it("should delay on second upload conflict", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await 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 = await createDataObject('item');
var lastLibraryVersion = 5;
var postCalls = 0;
var settingsCalls = 0;
var t;
server.respond(function (req) {
// On first and second upload attempts, return 412
if (req.method == "POST") {
if (!req.url.startsWith(baseURL + "users/1/items")) {
throw new Error("Unexpected POST");
}
postCalls++;
// 1st and 2nd requests
if (postCalls == 1 || postCalls == 2) {
req.respond(
412,
{
"Last-Modified-Version": ++lastLibraryVersion
},
""
);
}
// 3rd request
else {
let json = item.toResponseJSON();
json.version = ++lastLibraryVersion;
req.respond(
200,
{
"Last-Modified-Version": json.version
},
JSON.stringify({
successful: {
"0": json
},
unchanged: {},
failed: {}
})
);
}
t = new Date();
return;
}
if (req.method == "GET") {
if (req.url.startsWith(baseURL + "users/1/settings")) {
settingsCalls++;
if (settingsCalls == 2) {
assert.isAtLeast(new Date() - t, 75);
}
}
req.respond(
200,
{
"Last-Modified-Version": lastLibraryVersion
},
JSON.stringify({})
);
t = new Date();
return;
}
});
Zotero.Sync.Data.conflictDelayIntervals = [75, 70000];
await engine.start();
assert.equal(postCalls, 3);
assert.equal(settingsCalls, 2);
assert.isTrue(item.synced);
assert.equal(library.libraryVersion, lastLibraryVersion);
});
})
describe("#_startDownload()", function () {
it("shouldn't redownload objects that are already up to date", async function () {
var userLibraryID = Zotero.Libraries.userLibraryID;
//yield Zotero.Libraries.setVersion(userLibraryID, 5);
({ engine, client, caller } = await setup());
var objects = {};
for (let type of Zotero.DataObjectUtilities.getTypes()) {
let obj = objects[type] = createUnsavedDataObject(type);
obj.version = 5;
obj.synced = true;
await obj.saveTx({ skipSyncedUpdate: true });
await 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: {}
});
await engine._startDownload();
})
it("should apply remote deletions", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await setup());
// Create objects and mark them as synced
await Zotero.SyncedSettings.set(
library.id, 'tagColors', [{name: 'A', color: '#CC66CC'}], 1, true
);
var collection = createUnsavedDataObject('collection');
collection.synced = true;
var collectionID = await collection.saveTx({ skipSyncedUpdate: true });
var collectionKey = collection.key;
var search = createUnsavedDataObject('search');
search.synced = true;
var searchID = await search.saveTx({ skipSyncedUpdate: true });
var searchKey = search.key;
var item = createUnsavedDataObject('item');
item.synced = true;
var itemID = await 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]
}
});
await 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(await Zotero.Sync.Data.Local.getDateDeleted(
'setting', library.id, 'tagColors'
));
assert.isFalse(await Zotero.Sync.Data.Local.getDateDeleted(
'collection', library.id, collectionKey
));
assert.isFalse(await Zotero.Sync.Data.Local.getDateDeleted(
'search', library.id, searchKey
));
assert.isFalse(await Zotero.Sync.Data.Local.getDateDeleted(
'item', library.id, itemKey
));
})
it("should ignore remote deletions for non-item objects if local objects changed", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await setup());
// Create objects marked as unsynced
await Zotero.SyncedSettings.set(
library.id, 'tagColors', [{name: 'A', color: '#CC66CC'}]
);
var collection = createUnsavedDataObject('collection');
var collectionID = await collection.saveTx();
var collectionKey = collection.key;
var search = createUnsavedDataObject('search');
var searchID = await 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: []
}
});
await 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", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await setup());
// Create local unsynced items
var item = createUnsavedDataObject('item');
item.setField('title', 'A');
item.synced = false;
var itemID1 = await item.saveTx({ skipSyncedUpdate: true });
var itemKey1 = item.key;
item = createUnsavedDataObject('item');
item.setField('title', 'B');
item.synced = false;
var itemID2 = await 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]
}
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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();
})
await engine._startDownload();
await crPromise;
assert.isFalse(Zotero.Items.exists(itemID1));
assert.isTrue(Zotero.Items.exists(itemID2));
})
it("should handle new remote item referencing locally deleted collection", async function () {
var lastLibraryVersion = 5;
var newLibraryVersion = 6;
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
({ engine, client, caller } = await setup());
// Create local deleted collection
var collection = await createDataObject('collection');
var collectionKey = collection.key;
await collection.eraseTx();
var itemKey = "AAAAAAAA";
var headers = {
"Last-Modified-Version": newLibraryVersion
};
setDefaultResponses({
lastLibraryVersion,
libraryVersion: newLibraryVersion
});
setResponse({
method: "GET",
url: `users/1/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
status: 200,
headers,
json: {
[itemKey]: newLibraryVersion
}
});
setResponse({
method: "GET",
url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`,
status: 200,
headers,
json: [
makeItemJSON({
key: itemKey,
version: newLibraryVersion,
itemType: "book",
collections: [collectionKey]
})
]
});
await engine._startDownload();
// Item should be skipped and added to queue, which will allow collection deletion to upload
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
assert.sameMembers(keys, [itemKey]);
// Collection should not be in sync queue
assert.lengthOf(
await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', library.id), 0
);
});
it("should handle new remote item referencing locally missing collection", async function () {
var lastLibraryVersion = 5;
var newLibraryVersion = 6;
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
({ engine, client, caller } = await setup());
var collectionKey = 'AAAAAAAA';
var itemKey = 'BBBBBBBB'
var headers = {
"Last-Modified-Version": newLibraryVersion
};
setDefaultResponses({
lastLibraryVersion,
libraryVersion: newLibraryVersion
});
setResponse({
method: "GET",
url: `users/1/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
status: 200,
headers,
json: {
[itemKey]: newLibraryVersion
}
});
setResponse({
method: "GET",
url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`,
status: 200,
headers,
json: [
makeItemJSON({
key: itemKey,
version: newLibraryVersion,
itemType: "book",
collections: [collectionKey]
})
]
});
await engine._startDownload();
// Item should be skipped and added to queue
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
assert.sameMembers(keys, [itemKey]);
// Collection should be in queue
assert.sameMembers(
await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('collection', library.id),
[collectionKey]
);
});
it("should handle conflict with remote item referencing deleted local collection", async function () {
var lastLibraryVersion = 5;
var newLibraryVersion = 6;
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
({ engine, client, caller } = await setup());
// Create local deleted collection and item
var collection = await createDataObject('collection');
var collectionKey = collection.key;
await collection.eraseTx();
var item = await createDataObject('item');
var itemResponseJSON = item.toResponseJSON();
// Add collection to remote item
itemResponseJSON.data.collections = [collectionKey];
var itemKey = item.key;
await item.eraseTx();
var headers = {
"Last-Modified-Version": newLibraryVersion
};
setDefaultResponses({
lastLibraryVersion,
libraryVersion: newLibraryVersion
});
setResponse({
method: "GET",
url: `users/1/items?format=versions&since=${lastLibraryVersion}&includeTrashed=1`,
status: 200,
headers,
json: {
[itemKey]: newLibraryVersion
}
});
setResponse({
method: "GET",
url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`,
status: 200,
headers,
json: [itemResponseJSON]
});
await engine._startDownload();
// Item should be skipped and added to queue, which will allow collection deletion to upload
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', library.id);
assert.sameMembers(keys, [itemKey]);
});
it("should handle cancellation of conflict resolution window", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await setup());
var item = await createDataObject('item');
var itemID = await 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?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: []
}
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
wizard.getButton('cancel').click();
})
var e = await getPromiseError(engine._startDownload());
await crPromise
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 = await 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", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await setup());
// Create local unsynced items
var item = createUnsavedDataObject('item');
item.setField('title', 'A');
item.synced = false;
var itemID1 = await item.saveTx();
var itemKey1 = item.key;
item = createUnsavedDataObject('item');
item.setField('title', 'B');
item.synced = false;
var itemID2 = await 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]
}
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
wizard.getButton('cancel').click();
})
var e = await getPromiseError(engine._startDownload());
await crPromise;
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", async function () {
var library = Zotero.Libraries.userLibrary;
library.libraryVersion = 5;
await library.saveTx();
({ engine, client, caller } = await 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.isAtLeast(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];
await engine._startDownload();
assert.equal(calls, 2);
assert.equal(library.libraryVersion, lastLibraryVersion);
});
});
describe("#_downloadUpdatedObjects()", function () {
it("should include objects in sync queue", async function () {
({ engine, client, caller } = await setup());
var libraryID = Zotero.Libraries.userLibraryID;
var objectType = 'collection';
var objectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
await Zotero.Sync.Data.Local.addObjectsToSyncQueue(
objectType, libraryID, ["BBBBBBBB", "CCCCCCCC"]
);
await 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");
await 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", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var libraryID = Zotero.Libraries.userLibraryID;
var objectType = 'collection';
var objectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
await Zotero.Sync.Data.Local.addObjectsToSyncQueue(
objectType, libraryID, ["BBBBBBBB", "CCCCCCCC"]
);
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: "users/1/collections?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
})
]
});
await engine._downloadObjects(objectType, ["AAAAAAAA", "BBBBBBBB", "CCCCCCCC"]);
// Missing object should have been removed, but invalid object should remain
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue(objectType, libraryID);
assert.sameMembers(keys, ['BBBBBBBB']);
});
it("should add items that exist remotely in a locally deleted, remotely modified collection back to collection", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var libraryID = Zotero.Libraries.userLibraryID;
var collection = await createDataObject('collection');
var collectionKey = collection.key;
await collection.eraseTx();
var item1 = await createDataObject('item');
var item2 = await createDataObject('item', { deleted: true });
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: `users/1/collections?collectionKey=${collectionKey}`,
status: 200,
headers,
json: [
makeCollectionJSON({
key: collectionKey,
version: 5,
name: "A"
})
]
});
setResponse({
method: "GET",
url: `users/1/collections/${collectionKey}/items/top?format=keys`,
status: 200,
headers,
text: item1.key + "\n" + item2.key + "\n"
});
await engine._downloadObjects('collection', [collectionKey]);
var collection = Zotero.Collections.getByLibraryAndKey(libraryID, collectionKey);
assert.sameMembers(collection.getChildItems(true), [item1.id, item2.id]);
// Item should be removed from trash
assert.isFalse(item2.deleted);
});
it("should add locally deleted items that exist remotely in a locally deleted, remotely modified collection to sync queue and remove from delete log", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var libraryID = Zotero.Libraries.userLibraryID;
var collection = await createDataObject('collection');
var collectionKey = collection.key;
await collection.eraseTx();
var item = await createDataObject('item');
await item.eraseTx();
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: `users/1/collections?collectionKey=${collectionKey}`,
status: 200,
headers,
json: [
makeCollectionJSON({
key: collectionKey,
version: 5,
name: "A"
})
]
});
setResponse({
method: "GET",
url: `users/1/collections/${collectionKey}/items/top?format=keys`,
status: 200,
headers,
text: item.key + "\n"
});
await engine._downloadObjects('collection', [collectionKey]);
var collection = Zotero.Collections.getByLibraryAndKey(libraryID, collectionKey);
assert.sameMembers(
await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID),
[item.key]
);
assert.isFalse(
await Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, item.key)
);
});
it("should add object from failed download request to sync queue", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var libraryID = Zotero.Libraries.userLibraryID;
var item = await createDataObject('item');
var itemKey = item.key;
var headers = {
"Last-Modified-Version": 5
};
setResponse({
method: "GET",
url: `users/1/items?itemKey=${itemKey}&includeTrashed=1`,
status: 0,
headers,
body: ""
});
await engine._downloadObjects('item', [itemKey]);
assert.sameMembers(
await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID),
[itemKey]
);
});
});
describe("#_startUpload()", function () {
it("shouldn't upload unsynced objects if present in sync queue", async function () {
({ engine, client, caller } = await setup());
var libraryID = Zotero.Libraries.userLibraryID;
var objectType = 'item';
var obj = await createDataObject(objectType);
await Zotero.Sync.Data.Local.addObjectsToSyncQueue(objectType, libraryID, [obj.key]);
var result = await engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_NOTHING_TO_UPLOAD);
});
it("should allow applying remotely saved version to local annotation in group library", async function () {
var group = await createGroup({
libraryVersion: 5
});
var libraryID = group.libraryID;
({ engine, client, caller } = await setup({ libraryID }));
var createdByUserID = 2352512;
await Zotero.Users.setName(createdByUserID, 'user');
var attachment = await importFileAttachment('test.pdf', { libraryID });
attachment.synced = true;
await attachment.saveTx();
var annotation = await createAnnotation('highlight', attachment);
annotation.createdByUserID = createdByUserID;
await annotation.saveTx({
skipEditCheck: true
});
var responseJSON = annotation.toResponseJSON();
responseJSON.version = 10;
responseJSON.data.version = 10;
var newComment = 'new comment';
responseJSON.data.annotationComment = newComment;
let response = {
successful: {
"0": responseJSON
},
unchanged: {},
failed: {}
};
setResponse({
method: "POST",
url: `groups/${group.id}/items`,
status: 200,
headers: {
"Last-Modified-Version": 10
},
json: response
})
var result = await engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_SUCCESS);
assert.equal(annotation.version, 10);
assert.equal(annotation.annotationComment, newComment);
});
it("should prompt to reset library on 403 write response and reset on accept", async function () {
var group = await createGroup({
libraryVersion: 5
});
var libraryID = group.libraryID;
({ engine, client, caller } = await setup({ libraryID }));
var item = createUnsavedDataObject('item');
item.libraryID = libraryID;
item.setField('title', 'A');
item.synced = false;
var itemID = await 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 = await 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", async function () {
var group = await createGroup({
libraryVersion: 5
});
var libraryID = group.libraryID;
({ engine, client, caller } = await setup({ libraryID }));
var item = createUnsavedDataObject('item');
item.libraryID = libraryID;
item.setField('title', 'A');
item.synced = false;
var itemID = await 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 = await 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", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var item = createUnsavedDataObject('item');
item.version = lastLibraryVersion;
await 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 = await 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", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var collection1 = createUnsavedDataObject('collection');
// Set the collection as synced (though this shouldn't happen)
collection1.synced = true;
await collection1.saveTx();
var collection2 = await 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 = await 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", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var collection = createUnsavedDataObject('collection');
// Set the collection as synced (though this shouldn't happen)
collection.synced = true;
await collection.saveTx();
var item = await 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 = await 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", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 5;
library.libraryVersion = lastLibraryVersion;
await library.saveTx();
var item = createUnsavedDataObject('item');
// Set the parent item as synced (though this shouldn't happen)
item.synced = true;
await item.saveTx();
var note = await 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 = await engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_SUCCESS);
assert.equal(called, 2);
});
it("shouldn't retry failed child item if parent item failed during this sync", async function () {
({ engine, client, caller } = await setup({
stopOnError: false
}));
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var libraryVersion = 5;
library.libraryVersion = libraryVersion;
await library.saveTx();
var item1 = await createDataObject('item');
var item1JSON = item1.toResponseJSON();
var tag = "A".repeat(300);
var item2 = await createDataObject('item', { tags: [{ tag }] });
var note = await createDataObject('item', { itemType: 'note', parentID: item2.id });
var called = 0;
server.respond(function (req) {
let requestJSON = JSON.parse(req.requestBody);
if (called == 0) {
assert.lengthOf(requestJSON, 3);
assert.equal(requestJSON[0].key, item1.key);
assert.equal(requestJSON[1].key, item2.key);
assert.equal(requestJSON[2].key, note.key);
req.respond(
200,
{
"Last-Modified-Version": ++libraryVersion
},
JSON.stringify({
successful: {
"0": Object.assign(item1JSON, { version: libraryVersion })
},
unchanged: {},
failed: {
1: {
code: 413,
message: `Tag '${"A".repeat(50)}…' too long`,
data: {
tag
}
},
// Normally this would retry, but that might result in a 409
// without the parent
2: {
code: 500,
message: `An error occurred`
}
}
})
);
}
called++;
});
var spy = sinon.spy(engine, "onError");
var result = await engine._startUpload();
assert.equal(result, engine.UPLOAD_RESULT_SUCCESS);
assert.equal(called, 1);
assert.equal(spy.callCount, 2);
});
it("should show file-write-access-lost dialog on 403 for attachment upload in group", async function () {
var group = await createGroup({
filesEditable: true
});
var libraryID = group.libraryID;
var libraryVersion = 5;
group.libraryVersion = libraryVersion;
await group.saveTx();
({ engine, client, caller } = await setup({
libraryID,
stopOnError: false
}));
var item1 = await createDataObject('item', { libraryID });
var item2 = await importFileAttachment(
'test.png',
{
libraryID,
parentID: item1.id,
version: 5
}
);
var called = 0;
server.respond(function (req) {
let requestJSON = JSON.parse(req.requestBody);
if (called == 0) {
req.respond(
200,
{
"Last-Modified-Version": ++libraryVersion
},
JSON.stringify({
successful: {
0: item1.toResponseJSON({ version: libraryVersion })
},
unchanged: {},
failed: {
1: {
code: 403,
message: "File editing access denied"
}
}
})
);
}
else if (called == 1 && req.url == baseURL + `groups/${group.id}`) {
req.respond(
200,
{
"Last-Modified-Version": group.libraryVersion
},
JSON.stringify({
id: group.id,
version: group.libraryVersion,
data: {
id: group.id,
version: group.libraryVersion,
name: group.name,
owner: 10,
type: "Private",
description: "",
url: "",
libraryEditing: "members",
libraryReading: "all",
fileEditing: "admins"
}
})
);
}
called++;
});
var dialogShown = false;
waitForDialog().then(() => dialogShown = true);
var spy = sinon.spy(engine, "onError");
var result = await engine._startUpload();
assert.isTrue(dialogShown);
assert.equal(result, engine.UPLOAD_RESULT_RESTART);
assert.equal(called, 2);
assert.equal(spy.callCount, 0);
assert.isFalse(group.filesEditable);
assert.ok(Zotero.Items.get(item1.id));
assert.isFalse(Zotero.Items.get(item2.id));
});
});
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", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await 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] = await 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
await 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
await 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?itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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();
})
await engine._downloadObjects('item', objects.map(o => o.key));
await crPromise;
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].right.version);
// Cache versions should match remote
for (let i = 0; i < 2; i++) {
let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
'item', libraryID, objects[i].key, values[i].right.version
);
assert.propertyVal(cacheJSON, 'version', values[i].right.version);
assert.nestedPropertyVal(cacheJSON, 'data.title', values[i].right.title);
}
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should show conflict resolution window on note conflicts", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await 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
);
await 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
await 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());
await obj.saveTx({
skipDateModifiedUpdate: true
});
values[i].left.note = obj.note;
values[i].left.version = obj.getField('version');
}
setResponse({
method: "GET",
url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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();
});
await engine._downloadObjects('item', objects.map(o => o.key));
await crPromise;
assert.equal(objects[0].note, values[0].right.note);
assert.equal(objects[1].note, values[1].left.note);
assert.equal(objects[0].version, values[0].right.version);
assert.equal(objects[1].version, values[1].right.version);
assert.isTrue(objects[0].synced);
assert.isFalse(objects[1].synced);
// Cache versions should match remote
for (let i = 0; i < 2; i++) {
let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
'item', libraryID, objects[i].key, values[i].right.version
);
assert.propertyVal(cacheJSON, 'version', values[i].right.version);
assert.nestedPropertyVal(cacheJSON, 'data.note', values[i].right.note);
}
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should show conflict resolution window on annotation conflicts", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var values = [];
var dateAdded = Date.now() - 86400000;
var responseJSON = [];
var attachment = await importFileAttachment('test.pdf');
var annotation1 = await createAnnotation('highlight', attachment);
var annotation2 = await createAnnotation('note', attachment);
var annotation3 = await createAnnotation('image', attachment);
var objects = [annotation1, annotation2, annotation3];
for (let i = 0; i < objects.length; i++) {
values.push({
left: {},
right: {}
});
let item = objects[i];
item.version = 10;
item.dateAdded = Zotero.Date.dateToSQL(new Date(dateAdded), true);
// Set Date Modified values one minute apart to enforce order
item.dateModified = Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
);
await item.saveTx();
let jsonData = item.toJSON();
jsonData.key = item.key;
jsonData.version = 10;
let json = {
key: item.key,
version: jsonData.version,
data: jsonData
};
// Save original version in cache
await Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
// Make conflicting local and remote changes
if (i == 0) {
values[i].left.annotationText
= item.annotationText = Zotero.Utilities.randomString();
values[i].right.annotationText
= jsonData.annotationText = Zotero.Utilities.randomString();
}
else if (i == 1) {
values[i].left.annotationComment
= item.annotationComment = Zotero.Utilities.randomString();
values[i].right.annotationComment
= jsonData.annotationComment = Zotero.Utilities.randomString();
}
else if (i == 2) {
values[i].left.annotationColor
= item.annotationColor = '#000000';
values[i].right.annotationColor = jsonData.annotationColor = '#ffffff';
}
await item.saveTx({
skipDateModifiedUpdate: true
});
values[i].left.version = item.version;
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
}
setResponse({
method: "GET",
url: `users/1/items?itemKey=${objects.map(x => x.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[0];
// TODO: Make this function async and verify that annotation widgets show up here
// after rendering. This may not be possible as long as this is within XBL.
// 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');
wizard.getButton('next').click();
// 2 (remote)
assert.equal(mergeGroup.rightPane.getAttribute('selected'), 'true');
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
});
await engine._downloadObjects('item', objects.map(o => o.key));
await crPromise;
assert.equal(objects[0].annotationText, values[0].right.annotationText);
assert.equal(objects[1].annotationComment, values[1].left.annotationComment);
assert.equal(objects[2].annotationColor, values[2].right.annotationColor);
assert.equal(objects[0].version, values[0].right.version);
assert.equal(objects[1].version, values[1].right.version);
assert.equal(objects[2].version, values[2].right.version);
assert.isTrue(objects[0].synced);
assert.isFalse(objects[1].synced);
assert.isTrue(objects[2].synced);
// Cache versions should match remote
for (let i = 0; i < objects.length; i++) {
let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
'item', libraryID, objects[i].key, values[i].right.version
);
assert.propertyVal(cacheJSON, 'version', values[i].right.version);
if (i == 0) {
assert.nestedPropertyVal(
cacheJSON, 'data.annotationText', values[i].right.annotationText
);
}
else if (i == 1) {
assert.nestedPropertyVal(
cacheJSON, 'data.annotationComment', values[i].right.annotationComment
);
}
else if (i == 2) {
assert.nestedPropertyVal(
cacheJSON, 'data.annotationColor', values[i].right.annotationColor
);
}
}
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should resolve all remaining conflicts with local version", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var collectionA = await createDataObject('collection');
var collectionB = await createDataObject('collection');
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] = await createDataObject(
'item',
{
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
await Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
// Create remote version
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.publisher = jsonData.publisher = Zotero.Utilities.randomString();
values[i].right.collections = jsonData.collections = [collectionB.key];
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
obj.setField('title', Zotero.Utilities.randomString());
obj.setField('extra', Zotero.Utilities.randomString());
obj.setCollections([collectionA.key]);
await obj.saveTx({
skipDateModifiedUpdate: true
});
values[i].left.title = obj.getField('title');
values[i].left.extra = obj.getField('extra');
values[i].left.collections = [collectionA.key];
values[i].left.version = obj.version;
}
setResponse({
method: "GET",
url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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.resolveAllRemote')
);
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.resolveAllLocal')
);
resolveAll.click();
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
await engine._downloadObjects('item', objects.map(o => o.key));
await crPromise;
// First object should match remote
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[0].version, values[0].right.version);
assert.isTrue(objects[0].synced);
// Remaining objects should be marked as unsynced, with remote versions but original values,
// as if they were saved and then modified
assert.isFalse(objects[1].synced);
assert.equal(objects[1].version, values[1].right.version);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.isFalse(objects[2].synced);
assert.equal(objects[2].getField('title'), values[2].left.title);
assert.equal(objects[2].version, values[2].right.version);
// All cache versions should match remote
for (let i = 0; i < 3; i++) {
let cacheJSON = await Zotero.Sync.Data.Local.getCacheObject(
'item', libraryID, objects[i].key, values[i].right.version
);
assert.propertyVal(cacheJSON, 'version', values[i].right.version);
assert.nestedPropertyVal(cacheJSON, 'data.title', values[i].right.title);
}
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should resolve all remaining conflicts with remote version", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
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] = await createDataObject(
'item',
{
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
await Zotero.Sync.Data.Local.saveCacheObjects('item', libraryID, [json]);
// Create remote version
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
values[i].right.version = json.version = jsonData.version = 15;
responseJSON.push(json);
// Modify object locally
await modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
values[i].left.version = obj.version;
}
setResponse({
method: "GET",
url: `users/1/items?itemKey=${objects.map(o => o.key).join('%2C')}`
+ `&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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.resolveAllRemote')
);
wizard.getButton('next').click();
// 2 click Resolve All checkbox
assert.equal(mergeGroup.rightPane.getAttribute('selected'), 'true');
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllRemote')
);
resolveAll.click();
if (Zotero.isMac) {
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
}
else {
// TODO
}
wizard.getButton('finish').click();
})
await engine._downloadObjects('item', objects.map(o => o.key));
await crPromise;
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[0].version, values[0].right.version);
assert.isTrue(objects[0].synced);
assert.equal(objects[1].getField('title'), values[1].right.title);
assert.equal(objects[1].version, values[1].right.version);
assert.isTrue(objects[1].synced);
assert.equal(objects[2].getField('title'), values[2].right.title);
assert.equal(objects[2].version, values[2].right.version);
assert.isTrue(objects[2].synced);
var keys = await 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", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = await 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
await obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?itemKey=${obj.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightPane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftPane.groupbox.onclick);
// Select local deleted version
mergeGroup.leftPane.groupbox.click();
wizard.getButton('finish').click();
})
await engine._downloadObjects('item', [obj.key]);
await crPromise;
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
})
it("should handle local child note deletion, keeping deletion", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var responseJSON = [];
var parent = await 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;
await 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
await obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.note = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?itemKey=${obj.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightPane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftPane.groupbox.onclick);
// Select local deleted version
mergeGroup.leftPane.groupbox.click();
wizard.getButton('finish').click();
});
await engine._downloadObjects('item', [obj.key]);
await crPromise;
obj = Zotero.Items.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should restore locally deleted item", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = await 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
};
await obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
var crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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();
})
await engine._downloadObjects('item', [key]);
await crPromise;
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
assert.equal(obj.getField('title'), jsonData.title);
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
});
it("should handle local deletion and remote move to trash", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create object, generate JSON, and delete
var obj = await 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
};
await obj.eraseTx();
json.version = jsonData.version = 15;
jsonData.deleted = true;
responseJSON.push(json);
setResponse({
method: "GET",
url: `users/1/items?itemKey=${key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: responseJSON
});
await engine._downloadObjects('item', [key]);
assert.isFalse(objectsClass.exists(libraryID, key));
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
// Deletion should still be in sync delete log for uploading
assert.ok(await Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, key));
});
it("should delete locally trashed item on remote deletion", async function () {
var libraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var responseJSON = [];
// Create trashed object
var obj = createUnsavedDataObject(type);
obj.deleted = true;
await obj.saveTx();
setResponse({
method: "GET",
url: `users/1/deleted?since=10`,
status: 200,
headers: {
"Last-Modified-Version": 15
},
json: {
collections: [],
searches: [],
items: [obj.key],
}
});
await engine._downloadDeletions(10, 15);
// Local object should have been deleted
assert.isFalse(objectsClass.exists(libraryID, obj.key));
var keys = await Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID);
assert.lengthOf(keys, 0);
// Deletion shouldn't be in sync delete log
assert.isFalse(await Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, obj.key));
});
});
describe("#_updateGroupItemUsers()", function () {
it("should update createdByUserID and lastModifiedByUserID", async function () {
var { id: groupID, libraryID } = await createGroup();
({ engine, client, caller } = await setup({ libraryID }));
var item1 = await createDataObject('item', { libraryID }, { skipGroupItemsUserUpdate: true });
var item1DateModified = item1.dateModified;
var item2 = await createDataObject('item', { libraryID }, { skipGroupItemsUserUpdate: true });
var responseJSON = [
item1.toResponseJSON(),
item2.toResponseJSON()
];
responseJSON[0].meta.createdByUser = {
id: 152315,
username: "user152315",
name: "User 152315"
};
responseJSON[0].meta.lastModifiedByUser = {
id: 352352,
username: "user352352",
name: "User 352352"
};
responseJSON[1].meta.createdByUser = {
id: 346534,
username: "user346534",
name: "User 346534"
};
setResponse({
method: "GET",
url: `groups/${groupID}/items?itemKey=${item1.key}%2C${item2.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 5
},
json: responseJSON
});
await engine._updateGroupItemUsers();
assert.equal(item1.createdByUserID, 152315);
assert.equal(item1.lastModifiedByUserID, 352352);
assert.equal(item1.dateModified, item1DateModified);
assert.equal(item2.createdByUserID, 346534);
});
it("should use username if no name", async function () {
var { id: groupID, libraryID } = await createGroup();
({ engine, client, caller } = await setup({ libraryID }));
var item = await createDataObject('item', { libraryID }, { skipGroupItemsUserUpdate: true });
var responseJSON = [
item.toResponseJSON()
];
responseJSON[0].meta.createdByUser = {
id: 235235,
username: "user235235",
name: ""
};
setResponse({
method: "GET",
url: `groups/${groupID}/items?itemKey=${item.key}&includeTrashed=1`,
status: 200,
headers: {
"Last-Modified-Version": 6
},
json: responseJSON
});
await engine._updateGroupItemUsers();
assert.equal(item.createdByUserID, 235235);
assert.equal(Zotero.Users.getName(235235), 'user235235');
});
});
describe("#_upgradeCheck()", function () {
it("should upgrade a library last synced with the classic sync architecture", async function () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await setup());
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] = [await createDataObject(type)];
}
var time1 = "2015-05-01 01:23:45";
await Zotero.DB.queryAsync("UPDATE collections SET clientDateModified=?", time1);
await Zotero.DB.queryAsync("UPDATE savedSearches SET clientDateModified=?", time1);
await 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(await 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)
);
await 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 = await 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 = await Zotero.Sync.Data.Local.getSynced(type, userLibraryID);
assert.deepEqual(synced, [objects[type][0].key]);
assert.equal(objects[type][0].version, 10);
var unsynced = await 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", async function () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await 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;
await 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;
await 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;
await 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
+ "?" + 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
});
await 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);
await 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(await Zotero.Sync.Data.Local.getDateDeleted(
type, userLibraryID, objects[type][2].key
));
}
});
it("should reprocess remote deletions", async function () {
var userLibraryID = Zotero.Libraries.userLibraryID;
({ engine, client, caller } = await 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;
await 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;
await 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 crPromise = waitForWindow('chrome://zotero/content/merge.xhtml', function (dialog) {
var doc = dialog.document;
var wizard = doc.querySelector('wizard');
var mergeGroup = wizard.getElementsByTagName('merge-group')[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();
}
}
});
await engine._fullSync();
await crPromise;
// 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);
}
});
});
describe("#_restoreToServer()", function () {
it("should delete remote objects that don't exist locally and upload all local objects", async function () {
({ engine, client, caller } = await setup());
var library = Zotero.Libraries.userLibrary;
var libraryID = library.id;
var lastLibraryVersion = 10;
library.libraryVersion = library.storageVersion = lastLibraryVersion;
await library.saveTx();
lastLibraryVersion = 20;
var postData = {};
var deleteData = {};
var types = Zotero.DataObjectUtilities.getTypes();
var objects = {};
var objectJSON = {};
for (let type of types) {
objectJSON[type] = [];
}
var obj;
for (let type of types) {
objects[type] = [null];
// Create JSON for object that exists remotely and not locally,
// which should be deleted
objectJSON[type].push(makeJSONFunctions[type]({
key: Zotero.DataObjectUtilities.generateKey(),
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
// All other objects should be uploaded
// Object with outdated version
obj = await createDataObject(type, { synced: true, version: 5 });
objects[type].push(obj);
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
// Object marked as synced that doesn't exist remotely
obj = await createDataObject(type, { synced: true, version: 10 });
objects[type].push(obj);
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
// Object marked as synced that doesn't exist remotely
// but is in the remote delete log
obj = await createDataObject(type, { synced: true, version: 10 });
objects[type].push(obj);
objectJSON[type].push(makeJSONFunctions[type]({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString()
}));
}
// Child attachment
obj = await importFileAttachment(
'test.png',
{
parentID: objects.item[1].id,
synced: true,
version: 5
}
);
obj.attachmentSyncedModificationTime = new Date().getTime();
obj.attachmentSyncedHash = 'b32e33f529942d73bea4ed112310f804';
obj.attachmentSyncState = Zotero.Sync.Storage.Local.SYNC_STATE_IN_SYNC;
await obj.saveTx();
objects.item.push(obj);
objectJSON.item.push(makeJSONFunctions.item({
key: obj.key,
version: lastLibraryVersion,
name: Zotero.Utilities.randomString(),
itemType: 'attachment'
}));
for (let type of types) {
let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type);
let suffix = type == 'item' ? '&includeTrashed=1' : '';
let 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: {
"Last-Modified-Version": lastLibraryVersion
},
json
});
deleteData[type] = {
expectedVersion: lastLibraryVersion++,
keys: [objectJSON[type][0].key]
};
}
await Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: 2 });
var settingsJSON = { testSetting: { value: { foo: 2 } } }
postData.setting = {
expectedVersion: lastLibraryVersion++
};
for (let type of types) {
postData[type] = {
expectedVersion: lastLibraryVersion++
};
}
server.respond(function (req) {
try {
let plural = req.url.match(/users\/\d+\/([a-z]+e?s)/)[1];
let type = Zotero.DataObjectUtilities.getObjectTypeSingular(plural);
// Deletions
if (req.method == "DELETE") {
let data = deleteData[type];
let version = data.expectedVersion + 1;
if (req.url == baseURL + `users/1/${plural}?${type}Key=${data.keys.join(',')}`) {
req.respond(
204,
{
"Last-Modified-Version": version
},
""
);
}
}
// Settings
else if (req.method == "POST" && req.url.match(/users\/\d+\/settings/)) {
let data = postData.setting;
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"],
data.expectedVersion
);
let version = data.expectedVersion + 1;
let json = JSON.parse(req.requestBody);
assert.deepEqual(json, settingsJSON);
req.respond(
204,
{
"Last-Modified-Version": version
},
""
);
}
// Uploads
else if (req.method == "POST") {
let data = postData[type];
assert.equal(
req.requestHeaders["If-Unmodified-Since-Version"],
data.expectedVersion
);
let version = data.expectedVersion + 1;
let json = JSON.parse(req.requestBody);
let o1 = json.find(o => o.key == objectJSON[type][1].key);
assert.notProperty(o1, 'version');
let o2 = json.find(o => o.key == objectJSON[type][2].key);
assert.notProperty(o2, 'version');
let o3 = json.find(o => o.key == objectJSON[type][3].key);
assert.notProperty(o3, 'version');
if (type == 'item') {
let o = json.find(o => o.key == objectJSON.item[4].key);
assert.notProperty(o, 'version');
// Attachment items should include storage properties
assert.propertyVal(o, 'mtime', objects.item[4].attachmentSyncedModificationTime);
assert.propertyVal(o, 'md5', objects.item[4].attachmentSyncedHash);
}
let response = {
successful: {},
unchanged: {},
failed: {}
};
// Return objects in the order provided
json.map(x => x.key).forEach((key, index) => {
response.successful[index] = Object.assign(
objectJSON[type].find(x => x.key == key),
{ version }
);
});
req.respond(
200,
{
"Last-Modified-Version": version
},
JSON.stringify(response)
);
}
}
catch (e) {
Zotero.logError(e);
throw e;
}
});
await engine._restoreToServer();
// Check settings
var setting = Zotero.SyncedSettings.get(libraryID, "testSetting");
assert.deepEqual(setting, { foo: 2 });
var settingMetadata = Zotero.SyncedSettings.getMetadata(libraryID, "testSetting");
assert.equal(settingMetadata.version, postData.setting.expectedVersion + 1);
assert.isTrue(settingMetadata.synced);
// Objects should all be marked as synced and in the cache
for (let type of types) {
let version = postData[type].expectedVersion + 1;
for (let i = 1; i <= 3; i++) {
assert.equal(objects[type][i].version, version);
assert.isTrue(objects[type][i].synced);
await assertInCache(objects[type][i]);
}
}
// Files should be marked as unsynced
assert.equal(
objects.item[4].attachmentSyncState,
Zotero.Sync.Storage.Local.SYNC_STATE_TO_UPLOAD
);
});
});
})