zotero/test/tests/retractionsTest.js
Dan Stillman e88b7ee10f Don't clear the hidden flag when refreshing retractions data
_addEntry() wrote the row with REPLACE INTO, listing only itemID and
data, so flag reverted to its default of 0. It's called for every match
on every update, not just new ones, so a retraction the user had hidden
came back as soon as the list changed and Zotero restarted.
2026-09-04 15:07:15 -04:00

542 lines
16 KiB
JavaScript

describe("Retractions", function () {
var userLibraryID;
var win;
var zp;
var checkQueueItemsStub;
var retractedDOI = '10.1056/NEJMoa1200303'; // mixed case
before(async function () {
userLibraryID = Zotero.Libraries.userLibraryID;
win = await loadZoteroPane();
zp = win.ZoteroPane;
await Zotero.Retractions.updateFromServer();
// Remove debouncing on checkQueuedItems()
checkQueueItemsStub = sinon.stub(Zotero.Retractions, 'checkQueuedItems').callsFake(() => {
return Zotero.Retractions._checkQueuedItemsInternal();
});
});
beforeEach(async function () {
var ids = await Zotero.DB.columnQueryAsync("SELECT itemID FROM retractedItems");
if (ids.length) {
await Zotero.Items.erase(ids);
}
});
afterEach(async function () {
win.document.getElementById('retracted-items-close').click();
checkQueueItemsStub.resetHistory();
});
after(async function () {
win.close();
checkQueueItemsStub.restore();
var ids = await Zotero.DB.columnQueryAsync("SELECT itemID FROM retractedItems");
if (ids.length) {
await Zotero.Items.erase(ids);
}
});
async function createRetractedItem(options = {}) {
var o = {
itemType: 'journalArticle'
};
Object.assign(o, options);
var item = createUnsavedDataObject('item', o);
item.setField('DOI', retractedDOI);
if (Zotero.DB.inTransaction()) {
await item.save();
}
else {
await item.saveTx();
}
while (!checkQueueItemsStub.called) {
await Zotero.Promise.delay(50);
}
await checkQueueItemsStub.returnValues[0];
checkQueueItemsStub.resetHistory();
return item;
}
async function createRetractedItemWithExtraDOI(options = {}) {
var o = {
itemType: 'journalArticle'
};
Object.assign(o, options);
var item = createUnsavedDataObject('item', o);
item.setField('extra', 'DOI: ' + retractedDOI);
if (Zotero.DB.inTransaction()) {
await item.save();
}
else {
await item.saveTx();
}
while (!checkQueueItemsStub.called) {
await Zotero.Promise.delay(50);
}
await checkQueueItemsStub.returnValues[0];
checkQueueItemsStub.resetHistory();
return item;
}
function bannerShown() {
var container = win.document.getElementById('retracted-items-container');
if (container.hasAttribute('collapsed')) {
return false;
}
if (!container.hasAttribute('collapsed')) {
return true;
}
throw new Error("'collapsed' attribute not found");
}
describe("#updateFromServer()", function () {
var server;
var baseURL;
before(function () {
Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
baseURL = ZOTERO_CONFIG.API_URL + 'retractions/';
});
beforeEach(function () {
server = sinon.fakeServer.create();
server.autoRespond = true;
});
after(async function () {
Zotero.HTTP.mock = null;
// Restore the real list from the server. We could just mock it as part of the suite.
await Zotero.Retractions.updateFromServer();
});
/*it("shouldn't show banner or virtual collection for already flagged items on list update", async function () {
await Zotero.Retractions.updateFromServer();
});*/
it("should remove retraction flag from items that no longer match prefix list", async function () {
var doi = '10.1234/abcde';
var hash = Zotero.Utilities.Internal.sha1(doi);
var prefix = hash.substr(0, 5);
var lines = [
Zotero.Retractions.TYPE_DOI + prefix + ' 12345\n',
Zotero.Retractions.TYPE_DOI + 'aaaaa 23456\n'
];
var listCount = 0;
var searchCount = 0;
server.respond(function (req) {
if (req.method == 'GET' && req.url == baseURL + 'list') {
listCount++;
if (listCount == 1) {
req.respond(
200,
{
'Content-Type': 'text/plain',
'ETag': 'abcdefg'
},
lines.join('')
);
}
else if (listCount == 2) {
req.respond(
200,
{
'Content-Type': 'text/plain',
'ETag': 'bcdefgh'
},
lines[1]
);
}
}
else if (req.method == 'POST' && req.url == baseURL + 'search') {
searchCount++;
if (searchCount == 1) {
req.respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify([
{
doi: hash,
retractionDOI: '10.1234/bcdef',
date: '2019-01-02',
reasons: [
"Error in Data"
],
urls: []
}
])
);
}
}
});
await Zotero.Retractions.updateFromServer();
// Create item with DOI from list
var promise = waitForItemEvent('refresh');
var item = createUnsavedDataObject('item', { itemType: 'journalArticle' });
item.setField('DOI', doi);
await item.saveTx();
await promise;
assert.isTrue(Zotero.Retractions.isRetracted(item));
// Make a second request, with the entry removed
promise = waitForItemEvent('refresh');
await Zotero.Retractions.updateFromServer();
await promise;
assert.isFalse(Zotero.Retractions.isRetracted(item));
});
it("should refresh stored data for a version bump when the list is unchanged", async function () {
var doi = '10.1234/cdefg';
var hash = Zotero.Utilities.Internal.sha1(doi);
var line = Zotero.Retractions.TYPE_DOI + hash.substr(0, 5) + ' 12345\n';
var version = Zotero.Retractions._version;
var reason = "Error in Data";
server.respond(function (req) {
if (req.method == 'GET' && req.url == baseURL + 'list') {
// The list itself never changes, so a conditional request gets a 304
if (req.requestHeaders['If-None-Match'] == 'unchanged') {
req.respond(304, {}, '');
return;
}
req.respond(200, { 'Content-Type': 'text/plain', 'ETag': 'unchanged' }, line);
}
else if (req.method == 'POST' && req.url == baseURL + 'search') {
req.respond(
200,
{ 'Content-Type': 'application/json' },
JSON.stringify([
{
doi: hash,
retractionDOI: '10.1234/defgh',
date: '2019-01-02',
reasons: [reason],
urls: []
}
])
);
}
});
try {
await Zotero.Retractions.updateFromServer();
let promise = waitForItemEvent('refresh');
let item = createUnsavedDataObject('item', { itemType: 'journalArticle' });
item.setField('DOI', doi);
await item.saveTx();
await promise;
assert.sameMembers((await Zotero.Retractions.getData(item)).reasons, [reason]);
// An update with the same version gets a 304 and leaves the stored data alone
reason = "Error in Text";
await Zotero.Retractions.updateFromServer();
assert.sameMembers((await Zotero.Retractions.getData(item)).reasons, ["Error in Data"]);
// A version bump skips the conditional request and rewrites it
Zotero.Retractions._version = version + 1;
await Zotero.Retractions.updateFromServer();
assert.sameMembers((await Zotero.Retractions.getData(item)).reasons, [reason]);
}
finally {
Zotero.Retractions._version = version;
}
});
it("should keep a hidden retraction hidden across an update", async function () {
var doi = '10.1234/defgh';
var hash = Zotero.Utilities.Internal.sha1(doi);
var line = Zotero.Retractions.TYPE_DOI + hash.substr(0, 5) + ' 12345\n';
var etags = ['first', 'second'];
server.respond(function (req) {
if (req.method == 'GET' && req.url == baseURL + 'list') {
req.respond(
200,
{ 'Content-Type': 'text/plain', 'ETag': etags.shift() || 'second' },
line
);
}
else if (req.method == 'POST' && req.url == baseURL + 'search') {
req.respond(
200,
{ 'Content-Type': 'application/json' },
JSON.stringify([
{
doi: hash,
retractionDOI: '10.1234/efghi',
date: '2019-01-02',
reasons: ["Error in Data"],
urls: []
}
])
);
}
});
await Zotero.Retractions.updateFromServer();
let promise = waitForItemEvent('refresh');
let item = createUnsavedDataObject('item', { itemType: 'journalArticle' });
item.setField('DOI', doi);
await item.saveTx();
await promise;
assert.isTrue(Zotero.Retractions.isRetracted(item));
await Zotero.Retractions.hideRetraction(item);
assert.isFalse(Zotero.Retractions.isRetracted(item));
// The stored flag is what survives a restart, so it has to outlast an update
await Zotero.Retractions.updateFromServer();
assert.isFalse(Zotero.Retractions.isRetracted(item));
assert.equal(
await Zotero.DB.valueQueryAsync(
"SELECT flag FROM retractedItems WHERE itemID=?", item.id
),
Zotero.Retractions.FLAG_HIDDEN
);
});
});
describe("#shouldShowCitationWarning()", function () {
it("should return false if citation warning is hidden", async function () {
var item = await createRetractedItem();
assert.isTrue(Zotero.Retractions.shouldShowCitationWarning(item));
await Zotero.Retractions.disableCitationWarningsForItem(item);
assert.isFalse(Zotero.Retractions.shouldShowCitationWarning(item));
});
it("should return false if retraction is hidden", async function () {
var item = await createRetractedItem();
assert.isTrue(Zotero.Retractions.shouldShowCitationWarning(item));
await Zotero.Retractions.hideRetraction(item);
assert.isFalse(Zotero.Retractions.shouldShowCitationWarning(item));
});
});
describe("#getRetractionsFromJSON()", function () {
it("should identify object with retracted DOI", async function () {
var spy = sinon.spy(Zotero.HTTP, 'request');
var json = [
{
},
{
DOI: retractedDOI
},
{
DOI: '10.1234/abcd'
}
];
var indexes = await Zotero.Retractions.getRetractionsFromJSON(json);
assert.sameMembers(indexes, [1]);
assert.equal(spy.callCount, 1);
indexes = await Zotero.Retractions.getRetractionsFromJSON(json);
assert.sameMembers(indexes, [1]);
// Result should've been cached, so we should have it without another API request
assert.equal(spy.callCount, 1);
spy.restore();
});
it("should identify object with retracted DOI in Extra", async function () {
var json = [
{
extra: `DOI: ${retractedDOI}`
}
];
var indexes = await Zotero.Retractions.getRetractionsFromJSON(json);
assert.sameMembers(indexes, [0]);
});
it("should identify object with retracted DOI on subsequent line in Extra", async function () {
var json = [
{
extra: `Foo: Bar\nDOI: ${retractedDOI}`
}
];
var indexes = await Zotero.Retractions.getRetractionsFromJSON(json);
assert.sameMembers(indexes, [0]);
});
});
describe("Notification Banner", function () {
it("should show banner when retracted item is added", async function () {
assert.isFalse(bannerShown());
await createRetractedItem();
do {
await delay(10);
}
while (!bannerShown());
});
it("should show banner when retracted item with DOI in Extra is added", async function () {
assert.isFalse(bannerShown());
await createRetractedItemWithExtraDOI();
do {
await delay(10);
}
while (!bannerShown());
});
it("shouldn't show banner when item in trash is added", async function () {
await createRetractedItem({ deleted: true });
await delay(50);
assert.isFalse(bannerShown());
});
});
describe("virtual collection", function () {
it("should show/hide Retracted Items collection when a retracted item is found/erased", async function () {
// Create item
var item = await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
// Erase item
var promise = waitForItemEvent('refresh');
await item.eraseTx();
await promise;
assert.isFalse(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
});
it("should unhide Retracted Items collection when retracted item is found", async function () {
await createRetractedItem();
// Hide collection
await zp.setVirtual(userLibraryID, 'retracted', false);
// Add another retracted item, which should unhide it
await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
});
it("should hide Retracted Items collection when last retracted item is moved to trash", async function () {
var rowID = "R" + userLibraryID;
// Create item
var item = await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID(rowID));
// Select Retracted Items collection
await zp.collectionsView.selectByID(rowID);
await waitForItemsLoad(win);
// Erase item
item.deleted = true;
await item.saveTx();
await Zotero.Promise.delay(50);
// Retracted Items should be gone
assert.isFalse(zp.collectionsView.getRowIndexByID(rowID));
// And My Library should be selected
assert.equal(zp.collectionsView.selectedTreeRow.id, "L" + userLibraryID);
});
it("should hide Retracted Items collection when last retracted item is marked as hidden", async function () {
var rowID = "R" + userLibraryID;
// Create item
var item = await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID(rowID));
// Select Retracted Items collection
await zp.collectionsView.selectByID(rowID);
await waitForItemsLoad(win);
await Zotero.Retractions.hideRetraction(item);
await Zotero.Promise.delay(50);
// Retracted Items should be gone
assert.isFalse(zp.collectionsView.getRowIndexByID(rowID));
// And My Library should be selected
assert.equal(zp.collectionsView.selectedTreeRow.id, "L" + userLibraryID);
});
it("shouldn't hide Retracted Items collection when last retracted item is marked to not show a citation warning", async function () {
var rowID = "R" + userLibraryID;
// Create item
var item = await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID(rowID));
// Select Retracted Items collection
await zp.collectionsView.selectByID(rowID);
await waitForItemsLoad(win);
await Zotero.Retractions.disableCitationWarningsForItem(item);
await Zotero.Promise.delay(50);
// Should still be showing
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
});
it("should show Retracted Items collection when retracted item is restored from trash", async function () {
// Create trashed item
var item = await createRetractedItem({ deleted: true });
await Zotero.Promise.delay(50);
assert.isFalse(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
// Restore item
item.deleted = false;
await item.saveTx();
await Zotero.Promise.delay(50);
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
});
});
describe("retractions.enabled", function () {
beforeEach(function () {
Zotero.Prefs.clear('retractions.enabled');
});
it("should hide virtual collection and banner when false", async function () {
var item = await createRetractedItem();
await Zotero.Promise.delay(50);
var itemRetractionBox = win.document.getElementById('retraction-box');
assert.isFalse(itemRetractionBox.hidden);
var spies = [
sinon.spy(Zotero.Retractions, '_removeAllEntries'),
sinon.spy(Zotero.Retractions, 'isRetracted')
];
Zotero.Prefs.set('retractions.enabled', false);
while (!spies[0].called || !spies[1].called) {
await Zotero.Promise.delay(50);
}
await spies[0].returnValues[0];
await spies[1].returnValues[0];
spies.forEach(spy => spy.restore());
assert.isFalse(Zotero.Retractions.isRetracted(item));
assert.isFalse(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
assert.isFalse(bannerShown());
assert.isTrue(itemRetractionBox.hidden);
await item.eraseTx();
});
});
});