zotero/test/tests/server_connectorTest.js
Dan Stillman 5d1a2cba32 Remove the singular collection tree selection getters
Multi-collection selection left .collectionTreeRow and similar in place
to reduce breakage, but that would just leave plugins and other callers
potentially broken when multiple rows were selected. All singular
getters now throw and say what to use instead. getSelectedLibraryIDs()
was added to replace getSelectedLibraryID().
2026-07-29 13:12:47 -04:00

1871 lines
50 KiB
JavaScript

"use strict";
let httpRequest = (method, url, options) => {
if (!options) {
options = {};
}
if (!('errorDelayMax' in options)) {
options.errorDelayMax = 0;
}
if (!options.testBrowserRequest) {
options.headers = new Headers(options.headers || {});
options.headers.set('User-Agent', 'Some-API-Client/1.0');
}
delete options.testBrowserRequest;
return Zotero.HTTP.request(method, url, options);
}
describe("Connector Server", function () {
var { HttpServer } = ChromeUtils.importESModule("chrome://remote/content/server/httpd.sys.mjs");;
var win, connectorServerPath, testServerPath, httpd;
var testServerPort = 16213;
var snapshotHTML = "<html><head><title>Title</title><body>Body</body></html>";
before(function* () {
this.timeout(20000);
yield resetDB({
thisArg: this,
skipBundledFiles: true
});
yield Zotero.Translators.init();
win = yield loadZoteroPane();
connectorServerPath = 'http://127.0.0.1:' + Zotero.Server.port;
});
beforeEach(function () {
// Alternate ports to prevent exceptions not catchable in JS
testServerPort += (testServerPort & 1) ? 1 : -1;
testServerPath = 'http://127.0.0.1:' + testServerPort;
httpd = new HttpServer();
httpd.start(testServerPort);
httpd.registerPathHandler(
"/snapshot",
{
handle: function (request, response) {
response.setStatusLine(null, 200, "OK");
response.write(snapshotHTML);
}
}
);
});
afterEach(function* () {
for (let win of getWindows("chrome://zotero/content/progressQueueDialog.xhtml")) {
win.close();
}
var defer = Zotero.Promise.defer();
httpd.stop(() => defer.resolve());
yield defer.promise;
});
after(function () {
win.close();
});
describe("/connector/ping", function () {
it("should reject browser requests", async function () {
let error = await getPromiseError(httpRequest(
'GET',
connectorServerPath + "/connector/ping",
{
testBrowserRequest: true,
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.include(error.message, Zotero.getString('sync.error.checkConnection'));
});
it("should allow browser request if page is loaded directly", async function () {
let { response } = await httpRequest(
'GET',
connectorServerPath + "/connector/ping",
{
headers: {
'Sec-Fetch-Mode': 'navigate',
},
testBrowserRequest: true,
}
);
assert.include(response, 'Zotero is running');
});
});
describe('/connector/getTranslatorCode', function () {
it('should respond with translator code', async function () {
var code = 'function detectWeb() {}\nfunction doImport() {}';
var translator = buildDummyTranslator(4, code);
sinon.stub(Zotero.Translators, 'get').returns(translator);
var response = await httpRequest(
'POST',
connectorServerPath + "/connector/getTranslatorCode",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
translatorID: "dummy-translator",
})
}
);
assert.isTrue(Zotero.Translators.get.calledWith('dummy-translator'));
let translatorCode = await Zotero.Translators.getCodeForTranslator(translator);
assert.equal(response.response, translatorCode);
Zotero.Translators.get.restore();
})
});
describe("/connector/detect", function () {
it("should return relevant translators with proxies", async function () {
var code = 'function detectWeb() {return "newspaperArticle";}\nfunction doWeb() {}';
var translator = buildDummyTranslator("web", code, {target: "https://www.example.com/.*"});
sinon.stub(Zotero.Translators, 'getAllForType').resolves([translator]);
var response = await httpRequest(
'POST',
connectorServerPath + "/connector/detect",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
uri: "https://www-example-com.proxy.example.com/article",
html: "<head><title>Owl</title></head><body><p>🦉</p></body>"
})
}
);
assert.equal(JSON.parse(response.response)[0].proxy.scheme, 'https://%h.proxy.example.com/%p');
Zotero.Translators.getAllForType.restore();
});
});
describe("/connector/saveItems", function () {
it("should save a translated item to the current selected collection", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
var body = {
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
}
],
uri: "http://example.com"
};
var promise = waitForItemEvent('add');
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
// Check parent item
var ids = await promise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
assert.isTrue(collection.hasItem(item.id));
var req = await reqPromise;
assert.equal(req.status, 201);
});
it("should switch to My Library if read-only library is selected", async function () {
var group = await createGroup({
editable: false
});
await select(win, group);
var body = {
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: []
}
],
uri: "http://example.com"
};
var promise = waitForItemEvent('add');
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
successCodes: false
}
);
// My Library be selected, and the item should be in it
var ids = await promise;
assert.equal(
win.ZoteroPane.collectionsView.getSelectedLibraryIDs()[0],
Zotero.Libraries.userLibraryID
);
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
var req = await reqPromise;
assert.equal(req.status, 201);
});
it("should target only the focused row for a cross-library multiple-collection selection", async function () {
// A collection in My Library plus a collection in a group library, with the group
// collection focused. The Connector saves to a single target, so it should use the
// focused row and not the collection from the other library.
var group = await createGroup();
var userCollection = await createDataObject('collection');
var groupCollection = await createDataObject('collection', { libraryID: group.libraryID });
var cv = win.ZoteroPane.collectionsView;
// Reveal the group collection, then focus the My Library collection and toggle the
// group collection so it becomes the focused row
await select(win, groupCollection);
await select(win, userCollection);
var groupRow = cv.getRowIndexByID(groupCollection.treeViewID);
cv.selection.toggleSelect(groupRow);
await waitForItemsLoad(win);
// Sanity check: both rows selected, with the group collection focused
assert.equal(cv.selection.focused, groupRow);
assert.sameMembers(
win.ZoteroPane.getCollectionTreeRows().map(r => r.ref.libraryID),
[Zotero.Libraries.userLibraryID, group.libraryID]
);
var target = Zotero.Server.Connector.getSaveTarget();
assert.equal(target.library.libraryID, group.libraryID);
assert.equal(target.collection.id, groupCollection.id);
});
it("should use the provided proxy to deproxify item url", async function () {
await selectLibrary(win, Zotero.Libraries.userLibraryID);
await waitForItemsLoad(win);
var body = {
items: [
{
itemType: "newspaperArticle",
title: "Title",
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
],
attachments: [],
url: "https://www-example-com.proxy.example.com/path"
}
],
uri: "https://www-example-com.proxy.example.com/path",
proxy: {scheme: 'https://%h.proxy.example.com/%p'}
};
var promise = waitForItemEvent('add');
var req = await httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
// Check item
var ids = await promise;
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(item.getField('url'), 'https://www.example.com/path');
});
});
describe("/connector/saveSingleFile", function () {
it("should save a webpage item with /saveSnapshot", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
// Promise for item save
let promise = waitForItemEvent('add');
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID,
url: "http://example.com/test",
title,
};
await httpRequest(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
// Await item save
let parentIDs = await promise;
// Check parent item
assert.lengthOf(parentIDs, 1);
var item = Zotero.Items.get(parentIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'webpage');
assert.isTrue(collection.hasItem(item.id));
assert.equal(item.getField('title'), title);
// Promise for attachment save
promise = waitForItemEvent('add');
let body = JSON.stringify(Object.assign(payload, {
snapshotContent: await Zotero.File.getContentsAsync(indexPath)
}));
await httpRequest(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
// Await attachment save
let attachmentIDs = await promise;
// Check attachment
assert.lengthOf(attachmentIDs, 1);
item = Zotero.Items.get(attachmentIDs[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), title);
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, item.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
let expectedContents = await Zotero.File.getContentsAsync(indexPath);
assert.equal(contents, expectedContents);
});
it("should save a webpage item with /saveItems", async function () {
let collection = await createDataObject('collection');
await select(win, collection);
let title = Zotero.Utilities.randomString();
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID: sessionID,
items: [
{
itemType: "newspaperArticle",
title: title,
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
]
}
],
uri: "http://example.com"
};
let promise = waitForItemEvent('add');
let req = await httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
}
);
assert.equal(req.status, 201);
// Check parent item
let itemIDs = await promise;
assert.lengthOf(itemIDs, 1);
let item = Zotero.Items.get(itemIDs[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'newspaperArticle');
assert.isTrue(collection.hasItem(item.id));
// Promise for attachment save
promise = waitForItemEvent('add');
let testDataDirectory = getTestDataDirectory().path;
let indexPath = OS.Path.join(testDataDirectory, 'snapshot', 'index.html');
let body = JSON.stringify(Object.assign(payload, {
url: `${testServerPath}/attachment`,
snapshotContent: await Zotero.File.getContentsAsync(indexPath)
}));
req = await httpRequest(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body
}
);
assert.equal(req.status, 201);
// Await attachment save
let attachmentIDs = await promise;
// Check attachment
assert.lengthOf(attachmentIDs, 1);
item = Zotero.Items.get(attachmentIDs[0]);
assert.isTrue(item.isImportedAttachment());
assert.equal(item.getField('title'), 'Test');
// Check attachment html file
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, item.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getContentsAsync(path);
let expectedContents = await Zotero.File.getContentsAsync(indexPath);
assert.equal(contents, expectedContents);
});
});
describe("/connector/saveSnapshot", function () {
it("should save a webpage item to the current selected collection", async function () {
var collection = await createDataObject('collection');
await select(win, collection);
// saveSnapshot saves parent and child before returning
var ids;
var promise = waitForItemEvent('add').then(function (_ids) {
ids = _ids;
});
var promiseFulfilled = false;
promise.then(() => promiseFulfilled = true);
var file = getTestDataDirectory();
file.append('snapshot');
file.append('index.html');
httpd.registerFile("/test", file);
await httpRequest(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
url: `${testServerPath}/test`,
title: "Title"
})
}
);
assert.isTrue(promiseFulfilled);
// Check item
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'webpage');
assert.isTrue(collection.hasItem(item.id));
assert.equal(item.getField('title'), 'Title');
});
it("should switch to My Library if a read-only library is selected", async function () {
var group = await createGroup({
editable: false
});
await select(win, group);
var promise = waitForItemEvent('add');
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
url: testServerPath + '/snapshot',
html: snapshotHTML
}),
successCodes: false
}
);
// My Library be selected, and the item should be in it
var ids = await promise;
assert.equal(
win.ZoteroPane.collectionsView.getSelectedLibraryIDs()[0],
Zotero.Libraries.userLibraryID
);
assert.lengthOf(ids, 1);
var item = Zotero.Items.get(ids[0]);
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
var req = await reqPromise;
assert.equal(req.status, 201);
});
});
describe("/connector/saveAttachment", function () {
const pdfPath = OS.Path.join(getTestDataDirectory().path, 'test.pdf');
let pdfSample, pdfArrayBuffer;
before(async function () {
await selectLibrary(win, Zotero.Libraries.userLibraryID);
pdfSample = await Zotero.File.getSample(pdfPath);
pdfArrayBuffer = (await OS.File.read(pdfPath)).buffer;
});
it("should save a child item attachment to the specified parent item", async function () {
// First, save multiple items
const sessionID = Zotero.Utilities.randomString();
const bookItemID = Zotero.Utilities.randomString();
const articleItemID = Zotero.Utilities.randomString();
const body = {
sessionID,
items: [
{
id: bookItemID,
itemType: "book",
title: "Book Title",
},
{
id: articleItemID,
itemType: "journalArticle",
title: "Article Title",
}
]
};
let itemAddPromise = waitForItemEvent('add');
let saveItemsReq = await httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
assert.equal(saveItemsReq.status, 201);
let itemIDs = await itemAddPromise;
let bookItem = Zotero.Items.get(itemIDs[0]);
let articleItem = Zotero.Items.get(itemIDs[1]);
assert.equal(bookItem.numAttachments(), 0);
assert.equal(articleItem.numAttachments(), 0);
// Now save an attachment to the first parent item (book)
let attachmentAddPromise = waitForItemEvent('add');
let attachmentReq = await httpRequest(
'POST',
connectorServerPath + "/connector/saveAttachment",
{
headers: {
"Content-Type": "application/pdf",
"X-Metadata": JSON.stringify({
sessionID,
title: "Book Attachment",
parentItemID: bookItemID,
url: `${testServerPath}/attachment1.pdf`,
})
},
body: pdfArrayBuffer
}
);
assert.equal(attachmentReq.status, 201);
let attachmentIds = await attachmentAddPromise;
assert.lengthOf(attachmentIds, 1);
let attachment1 = Zotero.Items.get(attachmentIds[0]);
assert.equal(bookItem.numAttachments(), 1);
assert.equal(articleItem.numAttachments(), 0);
// Verify attachment was saved correctly
assert.equal(attachment1.parentItemID, bookItem.id);
assert.equal(attachment1.getField('title'), "Book Attachment");
assert.isTrue(attachment1.isPDFAttachment());
// Save a second attachment to the second parent item (article)
attachmentAddPromise = waitForItemEvent('add');
attachmentReq = await httpRequest(
'POST',
connectorServerPath + "/connector/saveAttachment",
{
headers: {
"Content-Type": "application/pdf",
"X-Metadata": JSON.stringify({
sessionID,
title: "Article Attachment",
parentItemID: articleItemID,
url: `${testServerPath}/attachment2.pdf`,
})
},
body: pdfArrayBuffer
}
);
assert.equal(attachmentReq.status, 201);
attachmentIds = await attachmentAddPromise;
assert.lengthOf(attachmentIds, 1);
var attachment2 = Zotero.Items.get(attachmentIds[0]);
// Verify second attachment was saved correctly
assert.equal(attachment2.parentItemID, articleItem.id);
assert.equal(attachment2.getField('title'), "Article Attachment");
assert.isTrue(attachment2.isPDFAttachment());
assert.equal(bookItem.numAttachments(), 1);
assert.equal(articleItem.numAttachments(), 1);
// Verify attachment content
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(attachment1).path;
let path = OS.Path.join(attachmentDirectory, attachment1.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getSample(path);
assert.equal(contents, pdfSample);
});
});
describe("/connector/hasAttachmentResolvers", function () {
it("should respond with 'true' if the item has OA attachments", async function () {
const sessionID = Zotero.Utilities.randomString();
const itemID = Zotero.Utilities.randomString();
const body = {
sessionID,
items: [
{
id: itemID,
itemType: "journalArticle",
title: "Test Article with DOI",
DOI: "10.1234/example.doi",
}
]
};
let response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
assert.equal(response.status, 201);
response = await httpRequest(
"POST",
connectorServerPath + "/connector/hasAttachmentResolvers",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
itemID
}),
}
);
assert.equal(response.status, 200);
assert.isTrue(JSON.parse(response.responseText));
});
it("should respond with 'true' if the item has a PMCID", async function () {
const sessionID = Zotero.Utilities.randomString();
const itemID = Zotero.Utilities.randomString();
const body = {
sessionID,
items: [
{
id: itemID,
itemType: "journalArticle",
title: "Test Article with PMCID",
PMCID: "PMC9262588",
}
]
};
let response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
assert.equal(response.status, 201);
response = await httpRequest(
"POST",
connectorServerPath + "/connector/hasAttachmentResolvers",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
itemID
}),
}
);
assert.equal(response.status, 200);
assert.isTrue(JSON.parse(response.responseText));
});
it("should respond with 'false' if the item has no OA attachments", async function () {
const sessionID = Zotero.Utilities.randomString();
const itemID = Zotero.Utilities.randomString();
const body = {
sessionID,
items: [
{
id: itemID,
itemType: "journalArticle",
title: "Test Article",
}
]
};
let response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
assert.equal(response.status, 201);
response = await httpRequest(
"POST",
connectorServerPath + "/connector/hasAttachmentResolvers",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
itemID
}),
}
);
assert.equal(response.status, 200);
assert.isFalse(JSON.parse(response.responseText));
});
});
describe("/connector/saveAttachmentFromResolver", function () {
it("should save an OA attachment for the specified item and return 201 if OA attachment is available", async function () {
let stub = sinon.stub(Zotero.Attachments, 'addFileFromURLs').returns({
id: Zotero.Utilities.randomString(),
getDisplayTitle: () => "OA Attachment"
});
try {
const sessionID = Zotero.Utilities.randomString();
const itemID = Zotero.Utilities.randomString();
const body = {
sessionID,
items: [
{
id: itemID,
itemType: "journalArticle",
title: "Test Article with DOI",
DOI: "10.1234/example.doi",
}
]
};
let response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
assert.equal(response.status, 201);
response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveAttachmentFromResolver",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
itemID
}),
}
);
assert.equal(response.status, 201);
assert.equal(response.responseText, "OA Attachment");
}
finally {
stub.restore();
}
});
it("should return 500 if OA attachment is not available", async function () {
let stub = sinon.stub(Zotero.Attachments, 'addFileFromURLs').returns(null);
try {
const sessionID = Zotero.Utilities.randomString();
const itemID = Zotero.Utilities.randomString();
const body = {
sessionID,
items: [
{
id: itemID,
itemType: "journalArticle",
title: "Test Article with DOI",
DOI: "10.1234/example.doi",
}
]
};
let response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
assert.equal(response.status, 201);
response = await httpRequest(
"POST",
connectorServerPath + "/connector/saveAttachmentFromResolver",
{
headers: {
"Content-Type": "application/json"
},
successCodes: false,
body: JSON.stringify({
sessionID,
itemID
}),
}
);
assert.equal(response.status, 500);
assert.equal(response.responseText, "Failed to save an attachment");
}
finally {
stub.restore();
}
});
});
describe("/connector/saveStandaloneAttachment", function () {
before(async function () {
await selectLibrary(win, Zotero.Libraries.userLibraryID);
});
it("should save a standalone PDF attachment", async function () {
const pdfPath = OS.Path.join(getTestDataDirectory().path, 'test.pdf');
const pdfSample = await Zotero.File.getSample(pdfPath);
const pdfArrayBuffer = (await OS.File.read(pdfPath)).buffer;
const attachmentInfo = {
url: `${testServerPath}/test1.pdf`,
title: "Test PDF1",
contentType: "application/pdf",
sessionID: Zotero.Utilities.randomString()
};
let itemIDsPromise = waitForItemEvent('add');
let xhr = await httpRequest(
'POST',
connectorServerPath + "/connector/saveStandaloneAttachment",
{
headers: {
"Content-Type": attachmentInfo.contentType,
"X-Metadata": JSON.stringify(attachmentInfo)
},
body: pdfArrayBuffer
}
);
assert.equal(xhr.status, 201);
assert.isTrue(JSON.parse(xhr.responseText).canRecognize);
let itemIDs = await itemIDsPromise;
let item = Zotero.Items.get(itemIDs[0]);
assert.equal(item.itemType, "attachment");
assert.equal(item.attachmentContentType, attachmentInfo.contentType);
assert.equal(item.getField("title"), attachmentInfo.title);
assert.equal(item.getField("url"), attachmentInfo.url);
// Check content
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, item.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getSample(path);
assert.equal(contents, pdfSample);
});
it("should save a standalone image attachment", async function () {
const imagePath = OS.Path.join(getTestDataDirectory().path, 'test.png');
const imageSample = await Zotero.File.getSample(imagePath);
const imageArrayBuffer = (await OS.File.read(imagePath)).buffer;
const attachmentInfo = {
url: `${testServerPath}/test.png`,
title: "Test PNG",
contentType: "image/png",
sessionID: Zotero.Utilities.randomString()
};
let itemIDsPromise = waitForItemEvent('add');
let xhr = await httpRequest(
'POST',
connectorServerPath + "/connector/saveStandaloneAttachment",
{
headers: {
"Content-Type": attachmentInfo.contentType,
"X-Metadata": JSON.stringify(attachmentInfo)
},
body: imageArrayBuffer
}
);
assert.equal(xhr.status, 201);
assert.isFalse(JSON.parse(xhr.responseText).canRecognize);
let itemIDs = await itemIDsPromise;
let item = Zotero.Items.get(itemIDs[0]);
assert.equal(item.itemType, "attachment");
assert.equal(item.attachmentContentType, attachmentInfo.contentType);
assert.equal(item.getField("title"), attachmentInfo.title);
assert.equal(item.getField("url"), attachmentInfo.url);
// Check content
let attachmentDirectory = Zotero.Attachments.getStorageDirectory(item).path;
let path = OS.Path.join(attachmentDirectory, item.attachmentFilename);
assert.isTrue(await OS.File.exists(path));
let contents = await Zotero.File.getSample(path);
assert.equal(contents, imageSample);
});
});
describe("/connector/getRecognizedItem", function () {
it("should return the recognized parent item", async function () {
const stub = sinon.stub(Zotero.RecognizeDocument, '_recognize').callsFake(async () => {
return await createDataObject('item', {
title: "Recognized Item",
});
});
try {
const pdfPath = OS.Path.join(getTestDataDirectory().path, 'test.pdf');
const pdfArrayBuffer = (await OS.File.read(pdfPath)).buffer;
const sessionID = Zotero.Utilities.randomString();
const attachmentInfo = {
url: `${testServerPath}/test2.pdf`,
title: "Test PDF2",
contentType: "application/pdf",
sessionID
};
let itemIDsPromise = waitForItemEvent('add');
let xhr = await httpRequest(
'POST',
connectorServerPath + "/connector/saveStandaloneAttachment",
{
headers: {
"Content-Type": attachmentInfo.contentType,
"X-Metadata": JSON.stringify(attachmentInfo)
},
body: pdfArrayBuffer
}
);
assert.equal(xhr.status, 201);
assert.isTrue(JSON.parse(xhr.responseText).canRecognize);
let itemIDs = await itemIDsPromise;
let standaloneAttachment = Zotero.Items.get(itemIDs[0]);
assert.isFalse(standaloneAttachment.parentID);
let recognizedItemIDsPromise = waitForItemEvent('add');
xhr = await httpRequest(
'POST',
connectorServerPath + "/connector/getRecognizedItem",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID
})
}
);
assert.isTrue(stub.called);
assert.equal(xhr.status, 200);
assert.equal(JSON.parse(xhr.responseText).title, "Recognized Item");
let recognizedItemIDs = await recognizedItemIDsPromise;
let recognizedItem = Zotero.Items.get(recognizedItemIDs[0]);
assert.equal(standaloneAttachment.parentID, recognizedItem.id);
}
finally {
stub.restore();
}
});
});
describe("/connector/updateSession", function () {
it("should update collections, tags, and notes of item saved via /saveItems", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await select(win, collection2);
const id = Zotero.Utilities.randomString();
var sessionID = Zotero.Utilities.randomString();
var body = {
sessionID,
items: [
{
itemType: "newspaperArticle",
title: "Title",
id,
creators: [
{
firstName: "First",
lastName: "Last",
creatorType: "author"
}
]
}
],
uri: "http://example.com"
};
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
var ids = await waitForItemEvent('add');
var item = Zotero.Items.get(ids[0]);
assert.isTrue(collection2.hasItem(item.id));
var req = await reqPromise;
assert.equal(req.status, 201);
reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveAttachment",
{
headers: {
"Content-Type": "text/html",
"X-Metadata": JSON.stringify({
sessionID,
title: "Attachment",
parentItemID: id,
url: `${testServerPath}/attachment`,
})
},
body: "<html><head><title>Title</title><body>Body</body></html>"
}
);
let childIDs = await waitForItemEvent('add');
req = await reqPromise;
assert.equal(req.status, 201);
var childItem = Zotero.Items.get(childIDs[0]);
assert.equal(childItem.getField('title'), "Attachment");
assert.equal(childItem.parentID, item.id);
// Update saved item
var req = await httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B",
note: "Test note"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
let note = Zotero.Items.get(item.getNotes())[0];
assert.equal(note.getNote(), "Test note");
});
it("should update collections and tags of a PDF saved via /saveStandaloneAttachment", async function () {
const sessionID = Zotero.Utilities.randomString();
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
await select(win, collection2);
const pdfPath = OS.Path.join(getTestDataDirectory().path, 'test.pdf');
const pdfArrayBuffer = (await OS.File.read(pdfPath)).buffer;
const attachmentInfo = {
url: `${testServerPath}/test1.pdf`,
title: "Test PDF1",
contentType: "application/pdf",
sessionID
};
let ids;
let promise = waitForItemEvent('add');
let req = await httpRequest(
'POST',
connectorServerPath + "/connector/saveStandaloneAttachment",
{
headers: {
"Content-Type": attachmentInfo.contentType,
"X-Metadata": JSON.stringify(attachmentInfo)
},
body: pdfArrayBuffer
}
);
ids = await promise;
let item = Zotero.Items.get(ids[0]);
assert.isTrue(collection2.hasItem(item.id));
assert.equal(req.status, 201);
// Update saved item
req = await httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
});
it("should update collections and tags of webpage saved via /saveSnapshot", async function () {
var sessionID = Zotero.Utilities.randomString();
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
await select(win, collection2);
// saveSnapshot saves parent and child before returning
var ids1, ids2;
var promise = waitForItemEvent('add').then(function (ids) {
ids1 = ids;
return waitForItemEvent('add').then(function (ids) {
ids2 = ids;
});
});
var promiseFulfilled = false;
promise.then(() => promiseFulfilled = true);
await httpRequest(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: testServerPath + '/snapshot',
title: "Title"
})
}
);
await httpRequest(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
title: "Snapshot",
url: `${testServerPath}/snapshot`,
snapshotContent: "<html><head><title>Title</title><body>Body</body></html>"
})
}
);
assert.isTrue(promiseFulfilled);
var item = Zotero.Items.get(ids1[0]);
// Update saved item
var req = await httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: collection1.treeViewID,
tags: "A, B",
note: "Test note"
})
}
);
assert.equal(req.status, 200);
assert.isTrue(collection1.hasItem(item.id));
assert.isTrue(item.hasTag("A"));
assert.isTrue(item.hasTag("B"));
let note = Zotero.Items.get(item.getNotes())[0];
assert.equal(note.getNote(), "Test note");
});
it("should move item saved via /saveItems to another library", async function () {
var group = await createGroup({ editable: true, filesEditable: false });
await select(win, group);
const id = Zotero.Utilities.randomString();
const sessionID = Zotero.Utilities.randomString();
let saveAttachment = () => {
return httpRequest(
'POST',
connectorServerPath + "/connector/saveAttachment",
{
headers: {
"Content-Type": "text/html",
"X-Metadata": JSON.stringify({
sessionID,
title: "Attachment",
parentItemID: id,
url: `${testServerPath}/attachment`,
})
},
body: "<html><head><title>Title</title><body>Body</body></html>"
}
);
};
var body = {
sessionID,
items: [
{
itemType: "newspaperArticle",
title: "Title",
id,
}
],
uri: "http://example.com"
};
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
}
);
var ids1 = await waitForItemEvent('add');
var item1 = Zotero.Items.get(ids1[0]);
var req = await reqPromise;
assert.equal(req.status, 201);
req = await saveAttachment();
// Attachment save returns with 200 since library files are not editable
assert.equal(req.status, 200);
assert.equal(req.responseText, "Library files are not editable.");
// Add a note
await httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID,
note: "Test note"
})
}
);
var note1ID = item1.getNotes()[0];
assert.isNumber(note1ID);
// Move item to user library where we can save files
reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
var ids2 = await waitForItemEvent('add');
req = await reqPromise;
assert.equal(req.status, 200);
reqPromise = saveAttachment();
await waitForItemEvent('add');
req = await reqPromise;
// Attachment is saved in user library
assert.equal(req.status, 201);
var item2 = Zotero.Items.get(ids2[0]);
assert.isFalse(Zotero.Items.exists(item1.id));
assert.equal(item2.libraryID, Zotero.Libraries.userLibraryID);
assert.equal(item2.numAttachments(), 1);
// Make sure the child note remains
assert.equal(item2.getNotes().length, 1);
let note = Zotero.Items.get(item2.getNotes())[0];
let note2ID = note.id;
assert.equal(note.getNote(), "Test note");
// Make sure the child note from another group is gone
assert.isFalse(Zotero.Items.get(note1ID));
// Move back to the file-editing restricted group
reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID,
note: note.getNote()
})
}
);
var ids3 = await waitForItemEvent('add');
var item3 = Zotero.Items.get(ids3[0]);
req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.equal(item3.libraryID, group.libraryID);
assert.equal(item3.numAttachments(), 0);
// Make sure the child note remains
assert.equal(item3.getNotes().length, 1);
note = Zotero.Items.get(item3.getNotes())[0];
assert.equal(note.getNote(), "Test note");
// Make sure the child note from another group is gone
assert.isFalse(Zotero.Items.get(note2ID));
});
it("should move item saved via /saveSnapshot to another library", async function () {
var group = await createGroup({ editable: true, filesEditable: false });
await select(win, group);
const sessionID = Zotero.Utilities.randomString();
let saveSingleFile = () => {
return httpRequest(
'POST',
connectorServerPath + "/connector/saveSingleFile",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
title: "Snapshot",
url: `${testServerPath}/snapshot`,
snapshotContent: "<html><head><title>Title</title><body>Body</body></html>"
})
}
);
};
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/saveSnapshot",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
url: testServerPath + '/snapshot',
})
}
);
var ids1 = await waitForItemEvent('add');
var req = await reqPromise;
assert.equal(req.status, 201);
var item1 = Zotero.Items.get(ids1[0]);
req = await saveSingleFile();
assert.equal(req.status, 200);
assert.equal(req.responseText, "Library files are not editable.");
// Move item to user library with file attachments
var reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: Zotero.Libraries.userLibrary.treeViewID
})
}
);
var ids2 = await waitForItemEvent('add');
var item2 = Zotero.Items.get(ids2[0]);
var req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item1.id));
assert.equal(item2.libraryID, Zotero.Libraries.userLibraryID);
req = await saveSingleFile();
assert.equal(req.status, 201);
assert.equal(item2.numAttachments(), 1);
// Move back to the file-editing restricted group
reqPromise = httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
sessionID,
target: group.treeViewID
})
}
);
var ids3 = await waitForItemEvent('add');
var item3 = Zotero.Items.get(ids3[0]);
req = await reqPromise;
assert.equal(req.status, 200);
assert.isFalse(Zotero.Items.exists(item2.id));
assert.equal(item3.libraryID, group.libraryID);
assert.equal(item3.numAttachments(), 0);
});
it("should delete added child note if its content is erased", async function () {
let collection = await createDataObject('collection');
await select(win, collection);
let sessionID = Zotero.Utilities.randomString();
let payload = {
sessionID,
items: [
{
itemType: "newspaperArticle",
title: "Note Test",
creators: [
{ firstName: "First", lastName: "Last", creatorType: "author" }
],
attachments: []
}
],
uri: "http://example.com"
};
let promise = waitForItemEvent('add');
// Create item
await httpRequest(
'POST',
connectorServerPath + "/connector/saveItems",
{
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
let ids = await promise;
let item = Zotero.Items.get(ids[0]);
// Add a note
await httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionID,
target: collection.treeViewID,
note: "Test note"
})
}
);
let notes = Zotero.Items.get(item.getNotes());
assert.isNotEmpty(notes);
assert.equal(notes[0].getNote(), "Test note");
// Erase the note
await httpRequest(
'POST',
connectorServerPath + "/connector/updateSession",
{
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionID,
target: collection.treeViewID,
note: ""
})
}
);
// Make sure the child note is removed
notes = Zotero.Items.get(item.getNotes());
assert.equal(notes.length, 0);
});
});
describe('/connector/installStyle', function () {
var endpoint;
var style;
before(function () {
endpoint = connectorServerPath + "/connector/installStyle";
style = `<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" version="1.0" default-locale="de-DE">
<info>
<title>Test1</title>
<id>http://www.example.com/test2</id>
<link href="http://www.zotero.org/styles/cell" rel="independent-parent"/>
</info>
</style>
`;
});
it('should reject styles with invalid text', async function () {
var error = await getPromiseError(httpRequest(
'POST',
endpoint,
{
headers: { "Content-Type": "application/json" },
body: '{}'
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.equal(error.xmlhttp.status, 400);
assert.equal(error.xmlhttp.responseText, Zotero.getString("styles.installError", "(null)"));
});
it('should import a style with application/vnd.citationstyles.style+xml content-type', async function () {
sinon.stub(Zotero.Styles, 'install').callsFake(function (style) {
var parser = new DOMParser(),
doc = parser.parseFromString(style, "application/xml");
return Promise.resolve({
styleTitle: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:title[1]', Zotero.Styles.ns
),
styleID: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:id[1]', Zotero.Styles.ns
)
});
});
var response = await httpRequest(
'POST',
endpoint,
{
headers: { "Content-Type": "application/vnd.citationstyles.style+xml" },
body: style
}
);
assert.equal(response.status, 201);
assert.equal(response.response, JSON.stringify({name: 'Test1'}));
Zotero.Styles.install.restore();
});
it('should accept text/plain request with X-Zotero-Connector-API-Version or Zotero-Allowed-Request', async function () {
sinon.stub(Zotero.Styles, 'install').callsFake(function (style) {
var parser = new DOMParser(),
doc = parser.parseFromString(style, "application/xml");
return Promise.resolve({
styleTitle: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:title[1]', Zotero.Styles.ns
),
styleID: Zotero.Utilities.xpathText(
doc, '/csl:style/csl:info[1]/csl:id[1]', Zotero.Styles.ns
)
});
});
// X-Zotero-Connector-API-Version
var response = await httpRequest(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain",
"X-Zotero-Connector-API-Version": "2"
},
body: style,
testBrowserRequest: true,
}
);
assert.equal(response.status, 201);
// Zotero-Allowed-Request
response = await httpRequest(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain",
"Zotero-Allowed-Request": "1"
},
body: style,
testBrowserRequest: true,
}
);
assert.equal(response.status, 201);
Zotero.Styles.install.restore();
});
it('should reject text/plain request without X-Zotero-Connector-API-Version', async function () {
var error = await getPromiseError(httpRequest(
'POST',
endpoint,
{
headers: {
"Content-Type": "text/plain"
},
body: style,
successCodes: [403],
testBrowserRequest: true,
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.include(error.message, Zotero.getString('sync.error.checkConnection'));
});
});
describe('/connector/import', function () {
var endpoint;
before(function () {
endpoint = connectorServerPath + "/connector/import";
});
it('should reject resources that do not contain import data', async function () {
const sessionID = Zotero.Utilities.randomString();
var error = await getPromiseError(httpRequest(
'POST',
endpoint + `?session=${sessionID}`,
{
headers: {
"Content-Type": "text/plain",
"X-Zotero-Connector-API-Version": "2"
},
body: 'Owl'
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.equal(error.xmlhttp.status, 400);
});
it('should reject requests without X-Zotero-Connector-API-Version', async function () {
const sessionID = Zotero.Utilities.randomString();
var error = await getPromiseError(httpRequest(
'POST',
endpoint + `?session=${sessionID}`,
{
headers: {
"Content-Type": "text/plain"
},
successCodes: [403],
testBrowserRequest: true,
}
));
assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException);
assert.include(error.message, Zotero.getString('sync.error.checkConnection'));
});
it('should import resources (BibTeX) into selected collection', async function () {
const sessionID = Zotero.Utilities.randomString();
var collection = await createDataObject('collection');
await select(win, collection);
var resource = `@book{test1,
title={Test1},
author={Owl},
year={1000},
publisher={Curly Braces Publishing},
keywords={A, B}
}`;
var addedItemIDsPromise = waitForItemEvent('add');
var req = await httpRequest(
'POST',
endpoint + `?session=${sessionID}`,
{
headers: {
"Content-Type": "application/x-bibtex",
"X-Zotero-Connector-API-Version": "2"
},
body: resource
}
);
assert.equal(req.status, 201);
assert.equal(JSON.parse(req.responseText)[0].title, 'Test1');
let itemIDs = await addedItemIDsPromise;
assert.isTrue(collection.hasItem(itemIDs[0]));
var item = Zotero.Items.get(itemIDs[0]);
assert.sameDeepMembers(item.getTags(), [{ tag: 'A', type: 1 }, { tag: 'B', type: 1 }]);
});
it('should switch to My Library if read-only library is selected', async function () {
const sessionID = Zotero.Utilities.randomString();
var group = await createGroup({
editable: false
});
await select(win, group);
var resource = `@book{test1,
title={Test1},
author={Owl},
year={1000},
publisher={Curly Braces Publishing}
}`;
var addedItemIDsPromise = waitForItemEvent('add');
var req = await httpRequest(
'POST',
endpoint + `?session=${sessionID}`,
{
headers: {
"Content-Type": "application/x-bibtex",
"X-Zotero-Connector-API-Version": "2"
},
body: resource,
successCodes: false
}
);
assert.equal(req.status, 201);
assert.equal(
win.ZoteroPane.collectionsView.getSelectedLibraryIDs()[0],
Zotero.Libraries.userLibraryID
);
let itemIDs = await addedItemIDsPromise;
var item = Zotero.Items.get(itemIDs[0]);
assert.equal(item.libraryID, Zotero.Libraries.userLibraryID);
});
});
});