"use strict"; describe("Zotero.DataObject", function () { var types = ['collection', 'item', 'search']; describe("#library", function () { it("should return a Zotero.Library", async function () { var item = await createDataObject('item'); assert.equal(item.library, Zotero.Libraries.userLibrary); }); }); describe("#libraryID", function () { it("should return a libraryID", async function () { var item = await createDataObject('item'); assert.isNumber(item.libraryID); assert.equal(item.libraryID, Zotero.Libraries.userLibraryID); }); }); describe("#key", function () { it("shouldn't update .loaded on get if unset", async function () { for (let type of types) { let param; if (type == 'item') { param = 'book'; } let obj = new (Zotero[Zotero.Utilities.capitalize(type)])(param); obj.libraryID = Zotero.Libraries.userLibraryID; assert.isNull(obj.key, 'key is null for ' + type); assert.isFalse(obj._loaded.primaryData, 'primary data not loaded for ' + type); obj.key = Zotero.DataObjectUtilities.generateKey(); } }) }) describe("#version", function () { it("should be set to 0 after creating object", async function () { for (let type of types) { let obj = await createDataObject(type); assert.equal(obj.version, 0); await obj.eraseTx(); } }) it("should be set after creating object", async function () { for (let type of types) { let obj = await createDataObject(type, { version: 1234 }); assert.equal(obj.version, 1234, type + " version mismatch"); await obj.eraseTx(); } }) }) describe("#clientVersion", function () { it("should be set to library clientVersion after creating object", async function () { for (let type of types) { let obj = await createDataObject(type); assert.equal(obj.clientVersion, obj.library.clientVersion); await obj.eraseTx(); } }); it("should load 0 as a number", async function () { for (let type of types) { let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type); let obj = await createDataObject(type); let id = obj.id; await Zotero.DB.queryAsync( `UPDATE ${objectsClass.table} SET clientVersion=0 WHERE ${objectsClass.idColumn}=?`, id ); objectsClass.unload(id); obj = await objectsClass.getAsync(id); assert.strictEqual(obj.clientVersion, 0, type + " clientVersion mismatch"); } }); it("should increase after modifying object", async function () { for (let type of types) { let obj = await createDataObject(type); let clientVersion = obj.clientVersion; await modifyDataObject(obj); assert.isAbove(obj.clientVersion, clientVersion); assert.equal(obj.clientVersion, obj.library.clientVersion); await obj.eraseTx(); } }); it("should increase once per library per transaction", async function () { for (let type of types) { let obj1 = await createDataObject(type); let obj2 = await createDataObject(type); let group = await getGroup(); let obj3 = await createDataObject(type, { libraryID: group.libraryID }); assert.isBelow(obj1.clientVersion, obj2.clientVersion); assert.equal(obj2.clientVersion, Zotero.Libraries.userLibrary.clientVersion); await modifyDataObject(obj1); assert.isAbove(obj1.clientVersion, obj2.clientVersion); assert.equal(obj1.clientVersion, Zotero.Libraries.userLibrary.clientVersion); let libraryVersionBefore = Zotero.Libraries.userLibrary.clientVersion; let groupVersionBefore = group.clientVersion; await Zotero.DB.executeTransaction(async () => { await modifyDataObject(obj1, undefined, { tx: false }); await modifyDataObject(obj2, undefined, { tx: false }); await modifyDataObject(obj3, undefined, { tx: false }); }); assert.equal(obj1.clientVersion, Zotero.Libraries.userLibrary.clientVersion); assert.equal(obj1.clientVersion, obj2.clientVersion); assert.notEqual(obj1.clientVersion, libraryVersionBefore); assert.equal(obj3.clientVersion, group.clientVersion); assert.notEqual(obj3.clientVersion, groupVersionBefore); } }); it("should increment library clientVersion when object is deleted", async function () { for (let type of types) { let obj = await createDataObject(type); let libraryVersion = obj.library.clientVersion; await obj.eraseTx(); assert.equal(obj.library.clientVersion, libraryVersion + 1); } }); }); describe("#synced", function () { it("should be set to false after creating object", async function () { for (let type of types) { var obj = createUnsavedDataObject(type); var id = await obj.saveTx(); assert.isFalse(obj.synced); await obj.eraseTx(); } }); it("should be set to false after modifying object", async function () { for (let type of types) { var obj = createUnsavedDataObject(type); var id = await obj.saveTx(); obj.synced = true; await obj.saveTx(); if (type == 'item') { obj.setField('title', Zotero.Utilities.randomString()); } else { obj.name = Zotero.Utilities.randomString(); } await obj.saveTx(); assert.isFalse(obj.synced); await obj.eraseTx(); } }); it("should be changed to true explicitly with no other changes", async function () { for (let type of types) { var obj = createUnsavedDataObject(type); var id = await obj.saveTx(); obj.synced = true; await obj.saveTx(); assert.isTrue(obj.synced); await obj.eraseTx(); } }); it("should be changed to true explicitly with other field changes", async function () { for (let type of types) { var obj = createUnsavedDataObject(type); var id = await obj.saveTx(); if (type == 'item') { obj.setField('title', Zotero.Utilities.randomString()); } else { obj.name = Zotero.Utilities.randomString(); } obj.synced = true; await obj.saveTx(); assert.isTrue(obj.synced); await obj.eraseTx(); } }); it("should remain at true if set explicitly", async function () { for (let type of types) { var obj = createUnsavedDataObject(type); obj.synced = true; var id = await obj.saveTx(); assert.isTrue(obj.synced); if (type == 'item') { obj.setField('title', 'test'); } else { obj.name = Zotero.Utilities.randomString(); } obj.synced = true; await obj.saveTx(); assert.isTrue(obj.synced); await obj.eraseTx(); } }); it("should be unchanged if skipSyncedUpdate passed", async function () { for (let type of types) { var obj = createUnsavedDataObject(type); var id = await obj.saveTx(); obj.synced = true; await obj.saveTx(); if (type == 'item') { obj.setField('title', Zotero.Utilities.randomString()); } else { obj.name = Zotero.Utilities.randomString(); } await obj.saveTx({ skipSyncedUpdate: true }); assert.ok(obj.synced); await obj.eraseTx(); } }); }) describe("#deleted", function () { it("should set trash status", async function () { for (let type of types) { let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type) let pluralClass = Zotero[Zotero.Utilities.capitalize(plural)]; // Set to true var obj = await createDataObject(type); assert.isFalse(obj.deleted, type); obj.deleted = true; // Sanity check for itemsTest#trash() if (type == 'item') { assert.isTrue(obj._changedData.deleted, type); } await obj.saveTx(); var id = obj.id; await pluralClass.reload(id, false, true); assert.isTrue(obj.deleted, type); // Set to false obj.deleted = false; await obj.saveTx(); await pluralClass.reload(id, false, true); assert.isFalse(obj.deleted, type); } }); }); describe("#loadPrimaryData()", function () { it("should load unloaded primary data if partially set", async function () { var objs = {}; for (let type of types) { let obj = createUnsavedDataObject(type); await obj.save({ skipCache: true }); objs[type] = { key: obj.key, version: obj.version }; } for (let type of types) { let obj = new (Zotero[Zotero.Utilities.capitalize(type)]); obj.libraryID = Zotero.Libraries.userLibraryID; obj.key = objs[type].key; await obj.loadPrimaryData(); assert.equal(obj.version, objs[type].version); } }); it("shouldn't overwrite item type set in constructor", async function () { var item = new Zotero.Item('book'); item.libraryID = Zotero.Libraries.userLibraryID; item.key = Zotero.DataObjectUtilities.generateKey(); await item.loadPrimaryData(); var saved = await item.saveTx(); assert.ok(saved); }); }) describe("#loadAllData()", function () { it("should load data on a regular item", async function () { var item = new Zotero.Item('book'); var id = await item.saveTx(); await item.loadAllData(); assert.throws(item.getNote.bind(item), 'getNote() can only be called on notes and attachments'); }) it("should load data on an attachment item", async function () { var item = new Zotero.Item('attachment'); var id = await item.saveTx(); await item.loadAllData(); assert.equal(item.note, ''); }) it("should load data on a note item", async function () { var item = new Zotero.Item('note'); var id = await item.saveTx(); await item.loadAllData(); assert.equal(item.note, ''); }) }) describe("#hasChanged()", function () { it("should return false if 'synced' was set but unchanged and nothing else changed", async function () { for (let type of types) { // True var obj = createUnsavedDataObject(type); obj.synced = true; var id = await obj.saveTx(); assert.isTrue(obj.synced); obj.synced = true; assert.isFalse(obj.hasChanged(), type + " shouldn't be changed"); // False var obj = createUnsavedDataObject(type); obj.synced = false; var id = await obj.saveTx(); assert.isFalse(obj.synced); obj.synced = false; assert.isFalse(obj.hasChanged(), type + " shouldn't be changed"); } }) it("should return true if 'synced' was set but unchanged and another primary field changed", async function () { for (let type of types) { let obj = createUnsavedDataObject(type); obj.synced = true; await obj.saveTx(); obj.synced = true; obj.version = 1234; assert.isTrue(obj.hasChanged()); } }) }); describe("#save()", function () { it("should add new identifiers to cache", async function () { for (let type of types) { let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type); let obj = createUnsavedDataObject(type); let id = await obj.saveTx(); let { libraryID, key } = objectsClass.getLibraryAndKeyFromID(id); assert.typeOf(key, 'string'); assert.equal(objectsClass.getIDFromLibraryAndKey(libraryID, key), id); } }) it("should reset changed state on objects", async function () { for (let type of types) { let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type); let obj = createUnsavedDataObject(type); await obj.saveTx(); assert.isFalse(obj.hasChanged()); } }) it("should handle additional tag change in the middle of a save", async function () { var item = await createDataObject('item'); item.setTags(['a']); var deferred = Zotero.Promise.defer(); var origFunc = Zotero.Notifier.queue.bind(Zotero.Notifier); sinon.stub(Zotero.Notifier, "queue").callsFake(function (event, type, ids, extraData) { // Add a new tag after the first one has been added to the DB and before the save is // finished. The changed state should've cleared before saving to the DB the first // time, so the second setTags() should mark the item as changed and allow the new tag // to be saved in the second saveTx(). if (event == 'add' && type == 'item-tag') { item.setTags(['a', 'b']); Zotero.Notifier.queue.restore(); deferred.resolve(item.saveTx()); } origFunc(...arguments); }); await Promise.all([item.saveTx(), deferred.promise]); assert.sameMembers(item.getTags().map(o => o.tag), ['a', 'b']); var tags = await Zotero.DB.columnQueryAsync( "SELECT name FROM tags JOIN itemTags USING (tagID) WHERE itemID=?", item.id ); assert.sameMembers(tags, ['a', 'b']); }); describe("Edit Check", function () { var group; before(function* () { group = yield createGroup({ editable: false }); }); it("should disallow saving to read-only libraries", async function () { let item = createUnsavedDataObject('item', { libraryID: group.libraryID }); var e = await getPromiseError(item.saveTx()); assert.ok(e); assert.include(e.message, "Cannot edit item"); }); it("should allow saving if skipEditCheck is passed", async function () { let item = createUnsavedDataObject('item', { libraryID: group.libraryID }); var e = await getPromiseError(item.saveTx({ skipEditCheck: true })); assert.isFalse(e); }); it("should allow saving if skipAll is passed", async function () { let item = createUnsavedDataObject('item', { libraryID: group.libraryID }); var e = await getPromiseError(item.saveTx({ skipAll: true })); assert.isFalse(e); }); }); }) describe("#erase()", function () { it("shouldn't trigger notifier if skipNotifier is passed", async function () { let observerIDs = []; let promises = []; for (let type of types) { let obj = await createDataObject(type); // For items, test automatic child item deletion if (type == 'item') { await createDataObject(type, { itemType: 'note', parentID: obj.id }); } let deferred = Zotero.Promise.defer(); promises.push(deferred.promise); observerIDs.push(Zotero.Notifier.registerObserver( { notify: function (event, type, ids) { if (event == 'delete' && ids.includes(obj.id)) { deferred.reject("Notifier called for erase on " + type); } } }, type, 'test' )); await obj.eraseTx({ skipNotifier: true }); } await Promise.race([ Promise.all(promises), // Give notifier time to trigger Zotero.Promise.delay(100), ]); for (let id of observerIDs) { Zotero.Notifier.unregisterObserver(id); } }) it("should delete object versions from sync cache", async function () { for (let type of types) { let obj = await createDataObject(type); let libraryID = obj.libraryID; let key = obj.key; let json = obj.toJSON(); await Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]); await obj.eraseTx(); let versions = await Zotero.Sync.Data.Local.getCacheObjectVersions( type, libraryID, key ); assert.lengthOf(versions, 0); } }) }) describe("#updateVersion()", function () { it("should update the object version", async function () { for (let type of types) { let obj = await createDataObject(type); assert.equal(obj.version, 0); await obj.updateVersion(1234); assert.equal(obj.version, 1234); assert.isFalse(obj.hasChanged()); obj.synced = true; assert.ok(obj.hasChanged()); await obj.updateVersion(1235); assert.equal(obj.version, 1235); assert.ok(obj.hasChanged()); await obj.eraseTx(); } }) }) describe("#updateSynced()", function () { it("should update the object sync status", async function () { for (let type of types) { let obj = await createDataObject(type); assert.isFalse(obj.synced); await obj.updateSynced(false); assert.isFalse(obj.synced); assert.isFalse(obj.hasChanged()); await obj.updateSynced(true); assert.ok(obj.synced); assert.isFalse(obj.hasChanged()); obj.version = 1234; assert.ok(obj.hasChanged()); await obj.updateSynced(false); assert.isFalse(obj.synced); assert.ok(obj.hasChanged()); await obj.eraseTx(); } }) it("should clear changed status", async function () { var item = createUnsavedDataObject('item'); item.synced = true; await item.saveTx(); // Only synced changed item.synced = false; assert.isTrue(item.hasChanged()); assert.isTrue(item._changed.primaryData.synced); await item.updateSynced(true); assert.isFalse(item.hasChanged()); // Should clear primary data change object assert.isUndefined(item._changed.primaryData); // Another primary field also changed item.setField('dateModified', '2017-02-27 12:34:56'); item.synced = false; assert.isTrue(item.hasChanged()); assert.isTrue(item._changed.primaryData.synced); await item.updateSynced(true); assert.isTrue(item.hasChanged()); // Should clear only 'synced' change status assert.isUndefined(item._changed.primaryData.synced); }); }) describe("Relations", function () { var types = ['collection', 'item']; function makeObjectURI(objectType) { var objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); return 'http://zotero.org/groups/1/' + objectTypePlural + '/' + Zotero.Utilities.generateObjectKey(); } describe("#addRelation()", function () { it("should add a relation to an object", async function () { for (let type of types) { let predicate = 'owl:sameAs'; let object = makeObjectURI(type); let obj = createUnsavedDataObject(type); obj.addRelation(predicate, object); await obj.saveTx(); var relations = obj.getRelations(); assert.property(relations, predicate); assert.include(relations[predicate], object); } }) }) describe("#removeRelation()", function () { it("should remove a relation from an object", async function () { for (let type of types) { let predicate = 'owl:sameAs'; let object = makeObjectURI(type); let obj = createUnsavedDataObject(type); obj.addRelation(predicate, object); await obj.saveTx(); obj.removeRelation(predicate, object); await obj.saveTx(); assert.lengthOf(Object.keys(obj.getRelations()), 0); } }) }) describe("#hasRelation()", function () { it("should return true if an object has a given relation", async function () { for (let type of types) { let predicate = 'owl:sameAs'; let object = makeObjectURI(type); let obj = createUnsavedDataObject(type); obj.addRelation(predicate, object); await obj.saveTx(); assert.ok(obj.hasRelation(predicate, object)); } }) }) describe("#setRelations()", function () { it("shouldn't allow invalid 'relations' predicates", async function () { var item = new Zotero.Item("book"); assert.throws(() => { item.setRelations({ "0": ["http://example.com/foo"] }); }); }); }); describe("#_getLinkedObject()", function () { it("should return a linked object in another library", async function () { var group = await getGroup(); var item1 = await createDataObject('item'); var item2 = await createDataObject('item', { libraryID: group.libraryID }); var item2URI = Zotero.URI.getItemURI(item2); await item2.addLinkedItem(item1); var linkedItem = await item1.getLinkedItem(item2.libraryID); assert.equal(linkedItem.id, item2.id); }) it("shouldn't return a linked item in the trash in another library", async function () { var group = await getGroup(); var item1 = await createDataObject('item'); var item2 = await createDataObject('item', { libraryID: group.libraryID }); var item2URI = Zotero.URI.getItemURI(item2); await item2.addLinkedItem(item1); item2.deleted = true; await item2.saveTx(); var linkedItem = await item1.getLinkedItem(item2.libraryID); assert.isFalse(linkedItem); }) it("shouldn't return reverse linked objects by default", async function () { var group = await getGroup(); var item1 = await createDataObject('item'); var item1URI = Zotero.URI.getItemURI(item1); var item2 = await createDataObject('item', { libraryID: group.libraryID }); await item2.addLinkedItem(item1); var linkedItem = await item2.getLinkedItem(item1.libraryID); assert.isFalse(linkedItem); }) it("should return reverse linked objects with bidirectional flag", async function () { var group = await getGroup(); var item1 = await createDataObject('item'); var item1URI = Zotero.URI.getItemURI(item1); var item2 = await createDataObject('item', { libraryID: group.libraryID }); await item2.addLinkedItem(item1); var linkedItem = await item2.getLinkedItem(item1.libraryID, true); assert.equal(linkedItem.id, item1.id); }) }) describe("#_addLinkedObject()", function () { it("should add an owl:sameAs relation", async function () { var group = await getGroup(); var item1 = await createDataObject('item'); var dateModified = item1.getField('dateModified'); var item2 = await createDataObject('item', { libraryID: group.libraryID }); var item2URI = Zotero.URI.getItemURI(item2); await item2.addLinkedItem(item1); var preds = item1.getRelationsByPredicate(Zotero.Relations.linkedObjectPredicate); assert.include(preds, item2URI); // Make sure Date Modified hasn't changed assert.equal(item1.getField('dateModified'), dateModified); }) }) }); describe("#fromJSON()", function () { it("should remove object from trash if 'deleted' property not provided", async function () { for (let type of types) { let obj = await createDataObject(type, { deleted: true }); assert.isTrue(obj.deleted, type); let json = obj.toJSON(); delete json.deleted; obj.fromJSON(json); await obj.saveTx(); assert.isFalse(obj.deleted, type); } }); }); describe("#toJSON()", function () { it("should output 'deleted' as true", function () { for (let type of types) { let obj = createUnsavedDataObject(type); obj.deleted = true; let json = obj.toJSON(); assert.isTrue(json.deleted, type); } }); it("shouldn't include 'deleted' if not set in default mode", function () { for (let type of types) { let obj = createUnsavedDataObject(type); let json = obj.toJSON(); assert.notProperty(json, 'deleted', type); } }); describe("'patch' mode", function () { it("should include changed 'deleted' field", async function () { for (let type of types) { let plural = Zotero.DataObjectUtilities.getObjectTypePlural(type) let pluralClass = Zotero[Zotero.Utilities.capitalize(plural)]; // True to false let obj = createUnsavedDataObject(type) obj.deleted = true; let id = await obj.saveTx(); obj = await pluralClass.getAsync(id); let patchBase = obj.toJSON(); obj.deleted = false; let json = obj.toJSON({ patchBase: patchBase }) assert.isUndefined(json.title, type); assert.isFalse(json.deleted, type); // False to true obj = createUnsavedDataObject(type); obj.deleted = false; id = await obj.saveTx(); obj = await pluralClass.getAsync(id); patchBase = obj.toJSON(); obj.deleted = true; json = obj.toJSON({ patchBase: patchBase }) assert.isUndefined(json.title, type); assert.isTrue(json.deleted, type); } }); }); }); })