diff --git a/.gitmodules b/.gitmodules index 2bcf31ec..1e50287d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "htdocs/zotero-schema"] path = htdocs/zotero-schema url = https://github.com/zotero/zotero-schema.git +[submodule "tests/remote_js/full-text-extractor"] + path = tests/remote_js/full-text-extractor + url = https://github.com/zotero/full-text-extractor.git +[submodule "tests/remote_js/full-text-indexer"] + path = tests/remote_js/full-text-indexer + url = https://github.com/zotero/full-text-indexer.git diff --git a/tests/remote_js/api2.js b/tests/remote_js/api2.js new file mode 100644 index 00000000..92296d63 --- /dev/null +++ b/tests/remote_js/api2.js @@ -0,0 +1,758 @@ + +const HTTP = require("./httpHandler"); +const { JSDOM } = require("jsdom"); +const wgxpath = require('wgxpath'); +var config = require('config'); + +class API2 { + static apiVersion = null; + + static useAPIVersion(version) { + this.apiVersion = version; + } + + static async login() { + const response = await HTTP.post( + `${config.apiURLPrefix}test/setup?u=${config.userID}&u2=${config.userID2}`, + " ", + {}, { + username: config.rootUsername, + password: config.rootPassword + }); + if (!response.data) { + throw new Error("Could not fetch credentials!"); + } + return JSON.parse(response.data); + } + + static async getItemTemplate(itemType) { + let response = await this.get(`items/new?itemType=${itemType}`); + return JSON.parse(response.data); + } + + static async createItem(itemType, data = {}, context = null, responseFormat = 'atom') { + let json = await this.getItemTemplate(itemType); + + if (data) { + for (let field in data) { + json[field] = data[field]; + } + } + + let response = await this.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + return this.handleCreateResponse('item', response, responseFormat, context); + } + + static async postItems(json) { + return this.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: json + }), + { "Content-Type": "application/json" } + ); + } + + static async postItem(json) { + return this.postItems([json]); + } + + static async groupCreateItem(groupID, itemType, context = null, responseFormat = 'atom') { + let response = await this.get(`items/new?itemType=${itemType}`); + let json = this.getJSONFromResponse(response); + + response = await this.groupPost( + groupID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + if (context && response.status != 200) { + throw new Error("Group post resurned status != 200"); + } + + json = this.getJSONFromResponse(response); + + if (responseFormat !== 'json' && Object.keys(json.success).length !== 1) { + console.log(json); + throw new Error("Item creation failed"); + } + + switch (responseFormat) { + case 'json': + return json; + + case 'key': + return Object.keys(json.success).shift(); + + case 'atom': { + let itemKey = Object.keys(json.success).shift(); + return this.groupGetItemXML(groupID, itemKey, context); + } + + default: + throw new Error(`Invalid response format '${responseFormat}'`); + } + } + + static async createAttachmentItem(linkMode, data = {}, parentKey = false, context = false, responseFormat = 'atom') { + let response = await this.get(`items/new?itemType=attachment&linkMode=${linkMode}`); + let json = JSON.parse(response.data); + + Object.keys(data).forEach((key) => { + json[key] = data[key]; + }); + + if (parentKey) { + json.parentItem = parentKey; + } + + response = await this.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + if (context && response.status !== 200) { + throw new Error("Response status is not 200"); + } + + json = this.getJSONFromResponse(response); + + if (responseFormat !== 'json' && Object.keys(json.success).length !== 1) { + console.log(json); + throw new Error("Item creation failed"); + } + + switch (responseFormat) { + case 'json': + return json; + + case 'key': + return json.success[0]; + + case 'atom': { + const itemKey = json.success[0]; + let xml = await this.getItemXML(itemKey, context); + + if (context) { + const data = this.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + if (linkMode !== json.linkMode) { + throw new Error("Link mode does not match"); + } + } + return xml; + } + + + default: + throw new Error(`Invalid response format '${responseFormat}'`); + } + } + + static async groupCreateAttachmentItem(groupID, linkMode, data = {}, parentKey = false, context = false, responseFormat = 'atom') { + let response = await this.get(`items/new?itemType=attachment&linkMode=${linkMode}`); + let json = JSON.parse(response.data); + + Object.keys(data).forEach((key) => { + json[key] = data[key]; + }); + + if (parentKey) { + json.parentItem = parentKey; + } + + response = await this.groupPost( + groupID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + if (context && response.status !== 200) { + throw new Error("Response status is not 200"); + } + + json = this.getJSONFromResponse(response); + + if (responseFormat !== 'json' && Object.keys(json.success).length !== 1) { + console.log(json); + throw new Error("Item creation failed"); + } + + switch (responseFormat) { + case 'json': + return json; + + case 'key': + return json.success[0]; + + case 'atom': { + const itemKey = json.success[0]; + let xml = await this.groupGetItemXML(groupID, itemKey, context); + + if (context) { + const data = this.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + if (linkMode !== json.linkMode) { + throw new Error("Link mode does not match"); + } + } + return xml; + } + + + default: + throw new Error(`Invalid response format '${responseFormat}'`); + } + } + + static async createNoteItem(text = "", parentKey = false, context = false, responseFormat = 'atom') { + let response = await this.get(`items/new?itemType=note`); + let json = JSON.parse(response.data); + + json.note = text; + + if (parentKey) { + json.parentItem = parentKey; + } + + response = await this.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + if (context && response.status !== 200) { + throw new Error("Response status is not 200"); + } + + json = this.getJSONFromResponse(response); + + if (responseFormat !== 'json' && Object.keys(json.success).length !== 1) { + console.log(json); + throw new Error("Item creation failed"); + } + + switch (responseFormat) { + case 'json': + return json; + + case 'key': + return json.success[0]; + + case 'atom': { + const itemKey = json.success[0]; + let xml = await this.getItemXML(itemKey, context); + + if (context) { + const data = this.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + if (text !== json.note) { + throw new Error("Text does not match"); + } + } + + return xml; + } + + default: + throw new Error(`Invalid response format '${responseFormat}'`); + } + } + + static async createCollection(name, data = {}, context = null, responseFormat = 'atom') { + let parent, relations; + + if (typeof data == 'object') { + parent = data.parentCollection ? data.parentCollection : false; + relations = data.relations ? data.relations : {}; + } + else { + parent = data || false; + relations = {}; + } + + const json = { + collections: [ + { + name: name, + parentCollection: parent, + relations: relations + } + ] + }; + + const response = await this.userPost( + config.userID, + `collections?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + + return this.handleCreateResponse('collection', response, responseFormat, context); + } + + static async createSearch(name, conditions = [], context = null, responseFormat = 'atom') { + if (conditions === 'default') { + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'test' + } + ]; + } + + const json = { + searches: [ + { + name: name, + conditions: conditions + } + ] + }; + + const response = await this.userPost( + config.userID, + `searches?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + + return this.handleCreateResponse('search', response, responseFormat, context); + } + + static async getLibraryVersion() { + const response = await this.userGet( + config.userID, + `items?key=${config.apiKey}&format=keys&limit=1` + ); + return response.headers["last-modified-version"][0]; + } + + static async getGroupLibraryVersion(groupID) { + const response = await this.groupGet( + groupID, + `items?key=${config.apiKey}&format=keys&limit=1` + ); + return response.headers["last-modified-version"][0]; + } + + static async getItemXML(keys, context = null) { + return this.getObjectXML('item', keys, context); + } + + static async groupGetItemXML(groupID, keys, context = null) { + if (typeof keys === 'string' || typeof keys === 'number') { + keys = [keys]; + } + + const response = await this.groupGet( + groupID, + `items?key=${config.apiKey}&itemKey=${keys.join(',')}&order=itemKeyList&content=json` + ); + if (context && response.status != 200) { + throw new Error("Group set request failed."); + } + return this.getXMLFromResponse(response); + } + + static async getCollectionXML(keys, context = null) { + return this.getObjectXML('collection', keys, context); + } + + static async getSearchXML(keys, context = null) { + return this.getObjectXML('search', keys, context); + } + + // Simple http requests with no dependencies + static async get(url, headers = {}, auth = null) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + + const response = await HTTP.get(url, headers, auth); + + if (config.verbose >= 2) { + console.log("\n\n" + response.data + "\n"); + } + + return response; + } + + static async superGet(url, headers = {}) { + return this.get(url, headers, { + username: config.username, + password: config.password + }); + } + + static async userGet(userID, suffix, headers = {}, auth = null) { + return this.get(`users/${userID}/${suffix}`, headers, auth); + } + + static async groupGet(groupID, suffix, headers = {}, auth = null) { + return this.get(`groups/${groupID}/${suffix}`, headers, auth); + } + + static async post(url, data, headers = {}, auth = null) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + + return HTTP.post(url, data, headers, auth); + } + + static async userPost(userID, suffix, data, headers = {}, auth = null) { + return this.post(`users/${userID}/${suffix}`, data, headers, auth); + } + + static async groupPost(groupID, suffix, data, headers = {}, auth = null) { + return this.post(`groups/${groupID}/${suffix}`, data, headers, auth); + } + + static async put(url, data, headers = {}, auth = null) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + + return HTTP.put(url, data, headers, auth); + } + + static async userPut(userID, suffix, data, headers = {}, auth = null) { + return this.put(`users/${userID}/${suffix}`, data, headers, auth); + } + + static async groupPut(groupID, suffix, data, headers = {}, auth = null) { + return this.put(`groups/${groupID}/${suffix}`, data, headers, auth); + } + + static async patch(url, data, headers = {}, auth = null) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + + return HTTP.patch(url, data, headers, auth); + } + + static async userPatch(userID, suffix, data, headers = {}) { + return this.patch(`users/${userID}/${suffix}`, data, headers); + } + + static async delete(url, headers = {}, auth = null) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + return HTTP.delete(url, headers, auth); + } + + static async userDelete(userID, suffix, headers = {}) { + return this.delete(`users/${userID}/${suffix}`, headers); + } + + static async head(url, headers = {}, auth = null) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + + return HTTP.head(url, headers, auth); + } + + static async userClear(userID) { + const response = await this.userPost( + userID, + "clear", + "", + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status !== 204) { + console.log(response.data); + throw new Error(`Error clearing user ${userID}`); + } + } + + static async groupClear(groupID) { + const response = await this.groupPost( + groupID, + "clear", + "", + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + + if (response.status !== 204) { + console.log(response.data); + throw new Error(`Error clearing group ${groupID}`); + } + } + + + // Response parsing + static arrayGetFirst(arr) { + try { + return arr[0]; + } + catch (e) { + return null; + } + } + + + static getXMLFromResponse(response) { + var result; + try { + const jsdom = new JSDOM(response.data, { contentType: "application/xml", url: "http://localhost/" }); + wgxpath.install(jsdom.window, true); + result = jsdom.window._document; + } + catch (e) { + console.log(response.data); + throw e; + } + return result; + } + + static getJSONFromResponse(response) { + const json = JSON.parse(response.data); + if (json === null) { + console.log(response.data); + throw new Error("JSON response could not be parsed"); + } + return json; + } + + static getFirstSuccessKeyFromResponse(response) { + const json = this.getJSONFromResponse(response); + if (!json.success || json.success.length === 0) { + console.log(response.data); + throw new Error("No success keys found in response"); + } + return json.success[0]; + } + + static parseDataFromAtomEntry(entryXML) { + const key = this.arrayGetFirst(entryXML.getElementsByTagName('zapi:key')); + const version = this.arrayGetFirst(entryXML.getElementsByTagName('zapi:version')); + const content = this.arrayGetFirst(entryXML.getElementsByTagName('content')); + if (content === null) { + console.log(entryXML.outerHTML); + throw new Error("Atom response does not contain "); + } + + return { + key: key ? key.textContent : null, + version: version ? version.textContent : null, + content: content ? content.textContent : null + }; + } + + static getContentFromResponse(response) { + const xml = this.getXMLFromResponse(response); + const data = this.parseDataFromAtomEntry(xml); + return data.content; + } + + // + static getPluralObjectType(objectType) { + if (objectType === 'search') { + return objectType + "es"; + } + return objectType + "s"; + } + + static async getObjectXML(objectType, keys, context = null) { + let objectTypePlural = this.getPluralObjectType(objectType); + + if (!Array.isArray(keys)) { + keys = [keys]; + } + + let response = await this.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&${objectType}Key=${keys.join(',')}&order=${objectType}KeyList&content=json` + ); + + // Checking the response status + if (context && response.status !== 200) { + throw new Error("Response status is not 200"); + } + + return this.getXMLFromResponse(response); + } + + static async handleCreateResponse(objectType, response, responseFormat, context = null) { + let uctype = objectType.charAt(0).toUpperCase() + objectType.slice(1); + + // Checking the response status + if (response.status !== 200) { + throw new Error("Response status is not 200"); + } + + let json = JSON.parse(response.data); + + if (responseFormat !== 'responsejson' && (!json.success || Object.keys(json.success).length !== 1)) { + return response; + //throw new Error(`${uctype} creation failed`); + } + + if (responseFormat === 'responsejson') { + return json; + } + + let key = json.success[0]; + + if (responseFormat === 'key') { + return key; + } + + // Calling the corresponding function based on the uctype + let xml; + switch (uctype) { + case 'Search': + xml = await this.getSearchXML(key, context); + break; + case 'Item': + xml = await this.getItemXML(key, context); + break; + case 'Collection': + xml = await this.getCollectionXML(key, context); + break; + } + + if (responseFormat === 'atom') { + return xml; + } + + let data = this.parseDataFromAtomEntry(xml); + + if (responseFormat === 'data') { + return data; + } + if (responseFormat === 'content') { + return data.content; + } + if (responseFormat === 'json') { + return JSON.parse(data.content); + } + + throw new Error(`Invalid response format '${responseFormat}'`); + } + + static async setKeyOption(userID, key, option, val) { + let response = await this.get( + `users/${userID}/keys/${key}`, + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + + // Checking the response status + if (response.status !== 200) { + console.log(response.data); + throw new Error(`GET returned ${response.status}`); + } + + let xml; + try { + xml = this.getXMLFromResponse(response); + } + catch (e) { + console.log(response.data); + throw e; + } + + for (let access of xml.getElementsByTagName('access')) { + switch (option) { + case 'libraryNotes': { + if (!access.hasAttribute('library')) { + break; + } + let current = parseInt(access.getAttribute('notes')); + if (current !== val) { + access.setAttribute('notes', val); + response = await this.put( + `users/${config.userID}/keys/${config.apiKey}`, + xml.documentElement.outerHTML, + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status !== 200) { + console.log(response.data); + throw new Error(`PUT returned ${response.status}`); + } + } + break; + } + + + case 'libraryWrite': { + if (!access.hasAttribute('library')) { + continue; + } + let current = parseInt(access.getAttribute('write')); + if (current !== val) { + access.setAttribute('write', val); + response = await this.put( + `users/${config.userID}/keys/${config.apiKey}`, + xml.documentElement.outerHTML, + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status !== 200) { + console.log(response.data); + throw new Error(`PUT returned ${response.status}`); + } + } + break; + } + } + } + } +} + +module.exports = API2; diff --git a/tests/remote_js/api3.js b/tests/remote_js/api3.js new file mode 100644 index 00000000..7ebfc60a --- /dev/null +++ b/tests/remote_js/api3.js @@ -0,0 +1,1080 @@ +const HTTP = require("./httpHandler"); +const { JSDOM } = require("jsdom"); +const Helpers = require("./helpers3"); +const fs = require("fs"); +var config = require('config'); +const wgxpath = require('wgxpath'); + +class API3 { + static schemaVersion; + + static apiVersion = 3; + + static apiKey = config.apiKey; + + static useAPIKey(key) { + this.apiKey = key; + } + + static useAPIVersion(version) { + this.apiVersion = version; + } + + static async login() { + const response = await HTTP.post( + `${config.apiURLPrefix}test/setup?u=${config.userID}&u2=${config.userID2}`, + " ", + {}, { + username: config.rootUsername, + password: config.rootPassword + }); + if (!response.data) { + throw new Error("Could not fetch credentials!"); + } + return JSON.parse(response.data); + } + + static async getLibraryVersion() { + const response = await this.userGet( + config.userID, + `items?key=${config.apiKey}&format=keys&limit=1` + ); + return response.headers["last-modified-version"][0]; + } + + static async getGroupLibraryVersion(groupID) { + const response = await this.groupGet( + groupID, + `items?key=${config.apiKey}&format=keys&limit=1` + ); + return response.headers["last-modified-version"][0]; + } + + + static async get(url, headers = {}, auth = false) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + if (this.schemaVersion) { + headers["Zotero-Schema-Version"] = this.schemaVersion; + } + if (!auth && this.apiKey) { + headers.Authorization = "Bearer " + this.apiKey; + } + let response = await HTTP.get(url, headers, auth); + if (config.verbose >= 2) { + console.log("\n\n" + response.data + "\n"); + } + return response; + } + + static async userGet(userID, suffix, headers = {}, auth = null) { + return this.get(`users/${userID}/${suffix}`, headers, auth); + } + + static async userPost(userID, suffix, data, headers = {}, auth = null) { + return this.post(`users/${userID}/${suffix}`, data, headers, auth); + } + + + static async head(url, headers = {}, auth = false) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + if (this.schemaVersion) { + headers["Zotero-Schema-Version"] = this.schemaVersion; + } + if (!auth && this.apiKey) { + headers.Authorization = "Bearer " + this.apiKey; + } + let response = await HTTP.head(url, headers, auth); + if (config.verbose >= 2) { + console.log("\n\n" + response.data + "\n"); + } + return response; + } + + static async userHead(userID, suffix, headers = {}, auth = null) { + return this.head(`users/${userID}/${suffix}`, headers, auth); + } + + static useSchemaVersion(version) { + this.schemaVersion = version; + } + + static async resetSchemaVersion() { + const schema = JSON.parse(fs.readFileSync("../../htdocs/zotero-schema/schema.json")); + this.schemaVersion = schema.version; + } + + + static async delete(url, headers = {}, auth = false) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + if (this.schemaVersion) { + headers["Zotero-Schema-Version"] = this.schemaVersion; + } + if (!auth && this.apiKey) { + headers.Authorization = "Bearer " + this.apiKey; + } + let response = await HTTP.delete(url, headers, auth); + return response; + } + + static async post(url, data, headers = {}, auth = false) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + if (this.schemaVersion) { + headers["Zotero-Schema-Version"] = this.schemaVersion; + } + if (!auth && this.apiKey) { + headers.Authorization = "Bearer " + this.apiKey; + } + let response = await HTTP.post(url, data, headers, auth); + return response; + } + + + static async superGet(url, headers = {}) { + return this.get(url, headers, { + username: config.rootUsername, + password: config.rootPassword + }); + } + + + static async superPost(url, data, headers = {}) { + return this.post(url, data, headers, { + username: config.rootUsername, + password: config.rootPassword + }); + } + + static async superDelete(url, headers = {}) { + return this.delete(url, headers, { + username: config.rootUsername, + password: config.rootPassword + }); + } + + static async createGroup(fields, returnFormat = 'id') { + const xmlDoc = new JSDOM(""); + const groupXML = xmlDoc.window.document.getElementsByTagName("group")[0]; + groupXML.setAttributeNS(null, "owner", fields.owner); + groupXML.setAttributeNS(null, "name", fields.name || "Test Group " + Math.random().toString(36).substring(2, 15)); + groupXML.setAttributeNS(null, "type", fields.type); + groupXML.setAttributeNS(null, "libraryEditing", fields.libraryEditing || 'members'); + groupXML.setAttributeNS(null, "libraryReading", fields.libraryReading || 'members'); + groupXML.setAttributeNS(null, "fileEditing", fields.fileEditing || 'none'); + groupXML.setAttributeNS(null, "description", ""); + groupXML.setAttributeNS(null, "url", ""); + groupXML.setAttributeNS(null, "hasImage", false); + + + let response = await this.superPost( + "groups", + xmlDoc.window.document.getElementsByTagName("body")[0].innerHTML + ); + if (response.status != 201) { + console.log(response.data); + throw new Error("Unexpected response code " + response.status); + } + + let url = response.headers.location[0]; + let groupID = parseInt(url.match(/\d+$/)[0]); + + // Add members + if (fields.members && fields.members.length) { + let xml = ''; + for (let member of fields.members) { + xml += ''; + } + let usersResponse = await this.superPost(`groups/${groupID}/users`, xml); + if (usersResponse.status != 200) { + console.log(usersResponse.data); + throw new Error("Unexpected response code " + usersResponse.status); + } + } + + if (returnFormat == 'response') { + return response; + } + if (returnFormat == 'id') { + return groupID; + } + throw new Error(`Unknown response format '${returnFormat}'`); + } + + static async deleteGroup(groupID) { + let response = await this.superDelete( + `groups/${groupID}` + ); + if (response.status != 204) { + console.log(response.data); + throw new Error("Unexpected response code " + response.status); + } + } + + static getSearchXML = async (keys, context = null) => { + return this.getObject('search', keys, context, 'atom'); + }; + + static async getContentFromAtomResponse(response, type = null) { + let xml = this.getXMLFromResponse(response); + let content = Helpers.xpathEval(xml, '//atom:entry/atom:content', true); + if (!content) { + console.log(content.documentElement.outerHTML); + throw new Error("Atom response does not contain "); + } + let subcontent = Helpers.xpathEval(xml, '//atom:entry/atom:content/zapi:subcontent', true, true); + if (subcontent) { + if (!type) { + throw new Error('$type not provided for multi-content response'); + } + let component; + switch (type) { + case 'json': + component = subcontent.filter(node => node.getAttribute('zapi:type') == 'json')[0]; + return JSON.parse(component.innerHTML); + + case 'html': + component = subcontent.filter(node => node.getAttribute('zapi:type') == 'html')[0]; + return component; + + default: + throw new Error("Unknown data type '$type'"); + } + } + else { + throw new Error("Unimplemented"); + } + } + + static async groupCreateItem(groupID, itemType, data = [], context = null, returnFormat = 'responseJSON') { + let response = await this.get(`items/new?itemType=${itemType}`); + let json = JSON.parse(response.data); + + if (data) { + for (let field in data) { + json[field] = data[field]; + } + } + + response = await this.groupPost( + groupID, + `items?key=${config.apiKey}`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + return this.handleCreateResponse('item', response, returnFormat, context, groupID); + } + + static async resetKey(key) { + let response = await this.get( + `keys/${key}`, + {}, + { + username: `${config.rootUsername}`, + password: `${config.rootPassword}` + } + ); + if (response.status != 200) { + console.log(response.data); + throw new Error(`GET returned ${response.status}`); + } + let json = this.getJSONFromResponse(response); + + + const resetLibrary = (lib) => { + for (const [permission, _] of Object.entries(lib)) { + lib[permission] = false; + } + }; + if (json.access.user) { + resetLibrary(json.access.user); + } + delete json.access.groups; + response = await this.put( + `users/${config.userID}/keys/${config.apiKey}`, + JSON.stringify(json), + {}, + { + username: `${config.rootUsername}`, + password: `${config.rootPassword}` + } + ); + if (response.status != 200) { + console.log(response.data); + throw new Error(`PUT returned ${response.status}`); + } + } + + static getItemXML = async (keys, context = null) => { + return this.getObject('item', keys, context, 'atom'); + }; + + static async groupGetItemXML(groupID, keys, context = null) { + if (typeof keys === 'string' || typeof keys === 'number') { + keys = [keys]; + } + + const response = await this.groupGet( + groupID, + `items?key=${config.apiKey}&itemKey=${keys.join(',')}&order=itemKeyList&content=json` + ); + if (context && response.status != 200) { + throw new Error("Group set request failed."); + } + return this.getXMLFromResponse(response); + } + + static async userClear(userID) { + const response = await this.userPost( + userID, + "clear", + "", + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status !== 204) { + console.log(response.data); + throw new Error(`Error clearing user ${userID}`); + } + } + + static async groupClear(groupID) { + const response = await this.groupPost( + groupID, + "clear", + "", + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + + if (response.status !== 204) { + console.log(response.data); + throw new Error(`Error clearing group ${groupID}`); + } + } + + static async parseLinkHeader(response) { + let header = response.headers.link; + let links = {}; + header.forEach(function (val) { + let matches = val.match(/<([^>]+)>; rel="([^"]+)"/); + links[matches[2]] = matches[1]; + }); + return links; + } + + static async getItem(keys, context = null, format = false, groupID = false) { + const mainObject = this || context; + return mainObject.getObject('item', keys, context, format, groupID); + } + + static async createAttachmentItem(linkMode, data = [], parentKey = false, context = false, returnFormat = 'responseJSON') { + let response = await this.get(`items/new?itemType=attachment&linkMode=${linkMode}`); + let json = JSON.parse(response.data); + + for (let key in data) { + json[key] = data[key]; + } + + if (parentKey) { + json.parentItem = parentKey; + } + + response = await this.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + + return this.handleCreateResponse('item', response, returnFormat, context); + } + + static async getItemResponse(keys, context = null, format = false, groupID = false) { + const mainObject = this || context; + return mainObject.getObjectResponse('item', keys, context, format, groupID); + } + + static async postObjects(objectType, json) { + let objectTypPlural = this.getPluralObjectType(objectType); + return this.userPost( + config.userID, + objectTypPlural, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + } + + + static async getCollection(keys, context = null, format = false, groupID = false) { + const module = this || context; + return module.getObject("collection", keys, context, format, groupID); + } + + static async patch(url, data, headers = {}, auth = false) { + let apiUrl = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + if (this.schemaVersion) { + headers["Zotero-Schema-Version"] = this.schemaVersion; + } + if (!auth && this.apiKey) { + headers.Authorization = "Bearer " + this.apiKey; + } + let response = await HTTP.patch(apiUrl, data, headers, auth); + return response; + } + + static createDataObject = async (objectType, data = false, context = false, format = 'json') => { + let template = await this.createUnsavedDataObject(objectType); + if (data) { + for (let key in data) { + template[key] = data[key]; + } + } + data = template; + let response; + switch (objectType) { + case 'collection': + return this.createCollection("Test", data, context, format); + + case 'item': + return this.createItem("book", data, context, format); + + case 'search': + response = await this.postObjects(objectType, [data]); + return this.handleCreateResponse('search', response, format, context); + } + return null; + }; + + static async put(url, data, headers = {}, auth = false) { + url = config.apiURLPrefix + url; + if (this.apiVersion) { + headers["Zotero-API-Version"] = this.apiVersion; + } + if (this.schemaVersion) { + headers["Zotero-Schema-Version"] = this.schemaVersion; + } + if (!auth && this.apiKey) { + headers.Authorization = "Bearer " + this.apiKey; + } + let response = await HTTP.put(url, data, headers, auth); + return response; + } + + static userPut = async (userID, suffix, data, headers = {}, auth = false) => { + return this.put(`users/${userID}/${suffix}`, data, headers, auth); + }; + + static userPatch = async (userID, suffix, data, headers = {}, auth = false) => { + return this.patch(`users/${userID}/${suffix}`, data, headers, auth); + }; + + static groupCreateAttachmentItem = async (groupID, linkMode, data = [], parentKey = false, context = false, returnFormat = 'responseJSON') => { + let response = await this.get(`items/new?itemType=attachment&linkMode=${linkMode}`); + let json = this.getJSONFromResponse(response); + for (let key in data) { + json[key] = data[key]; + } + if (parentKey) { + json.parentItem = parentKey; + } + + response = await this.groupPost( + groupID, + `items?key=${config.apiKey}`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + + return this.handleCreateResponse('item', response, returnFormat, context, groupID); + }; + + static async groupGet(groupID, suffix, headers = {}, auth = false) { + return this.get(`groups/${groupID}/${suffix}`, headers, auth); + } + + static async getCollectionXML(keys, context = null) { + return this.getObject('collection', keys, context, 'atom'); + } + + static async postItem(json) { + return this.postItems([json]); + } + + static async postItems(json) { + return this.postObjects('item', json); + } + + static async groupPut(groupID, suffix, data, headers = {}, auth = false) { + return this.put(`groups/${groupID}/${suffix}`, data, headers, auth); + } + + static async userDelete(userID, suffix, headers = {}, auth = false) { + let url = `users/${userID}/${suffix}`; + return this.delete(url, headers, auth); + } + + static async groupDelete(groupID, suffix, headers = {}, auth = false) { + let url = `groups/${groupID}/${suffix}`; + return this.delete(url, headers, auth); + } + + static getSuccessfulKeysFromResponse(response) { + let json = this.getJSONFromResponse(response); + return Object.keys(json.successful).map((o) => { + return json.successful[o].key; + }); + } + + static async getItemTemplate(itemType) { + let response = await this.get(`items/new?itemType=${itemType}`); + if (response.status != 200) { + console.log(response.status); + console.log(response.data); + throw new Error("Invalid response from template request"); + } + return JSON.parse(response.data); + } + + static async groupPost(groupID, suffix, data, headers = {}, auth = false) { + return this.post(`groups/${groupID}/${suffix}`, data, headers, auth); + } + + static async superPut(url, data, headers) { + return this.put(url, data, headers, { + username: config.rootUsername, + password: config.rootPassword + }); + } + + static async getSearchResponse(keys, context = null, format = false, groupID = false) { + const module = this || context; + return module.getObjectResponse('search', keys, context, format, groupID); + } + + // Atom + + static async getSearch(keys, context = null, format = false, groupID = false) { + const module = this || context; + return module.getObject('search', keys, context, format, groupID); + } + + static async createCollection(name, data = {}, context = null, returnFormat = 'responseJSON') { + let parent, relations; + + if (typeof data == 'object') { + parent = data.parentCollection ? data.parentCollection : false; + relations = data.relations ? data.relations : {}; + } + else { + parent = data ? data : false; + relations = {}; + } + + let json = [ + { + name: name, + parentCollection: parent, + relations: relations + } + ]; + + if (data.deleted) { + json[0].deleted = data.deleted; + } + + let response = await this.postObjects('collection', json); + return this.handleCreateResponse('collection', response, returnFormat, context); + } + + static async setKeyGroupPermission(key, groupID, permission, _) { + let response = await this.get( + "keys/" + key, + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status != 200) { + console.log(response.data); + throw new Error("GET returned " + response.status); + } + + let json = this.getJSONFromResponse(response); + if (!json.access) { + json.access = {}; + } + if (!json.access.groups) { + json.access.groups = {}; + } + json.access.groups[groupID] = json.access.groups[groupID] || {}; + json.access.groups[groupID][permission] = true; + response = await this.put( + "keys/" + key, + JSON.stringify(json), + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status != 200) { + console.log(response.data); + throw new Error("PUT returned " + response.status); + } + } + + static async setKeyOption(userID, key, option, val) { + console.log("setKeyOption() is deprecated -- use setKeyUserPermission()"); + + switch (option) { + case 'libraryNotes': + option = 'notes'; + break; + + case 'libraryWrite': + option = 'write'; + break; + } + + await this.setKeyUserPermission(key, option, val); + } + + + static async createNoteItem(text = "", parentKey = false, context = false, returnFormat = 'responseJSON') { + let response = await this.get("items/new?itemType=note"); + let json = JSON.parse(response.data); + json.note = text; + if (parentKey) { + json.parentItem = parentKey; + } + + response = await this.postObjects('item', [json]); + return this.handleCreateResponse('item', response, returnFormat, context); + } + + static async createItem(itemType, data = {}, context = null, returnFormat = 'responseJSON') { + let json = await this.getItemTemplate(itemType); + + if (data) { + for (let field in data) { + json[field] = data[field]; + } + } + + let headers = { + "Content-Type": "application/json", + "Zotero-API-Key": config.apiKey + }; + + let requestBody = JSON.stringify([json]); + + let response = await this.userPost(config.userID, "items", requestBody, headers); + + return this.handleCreateResponse('item', response, returnFormat, context); + } + + static async setKeyUserPermission(key, permission, value) { + let response = await this.get( + "keys/" + key, + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + if (response.status != 200) { + console.log(response.data); + throw new Error("GET returned " + response.status); + } + + if (this.apiVersion >= 3) { + let json = this.getJSONFromResponse(response); + + switch (permission) { + case 'library': + if (json.access?.user && value == json.access?.user.library) { + break; + } + json.access = json.access || {}; + json.access.user = json.access.user || {}; + json.access.user.library = value; + break; + + case 'write': + if (json.access?.user && value == json.access?.user.write) { + break; + } + json.access = json.access || {}; + json.access.user = json.access.user || {}; + json.access.user.write = value; + break; + + case 'notes': + if (json.access?.user && value == json.access?.user.notes) { + break; + } + json.access = json.access || {}; + json.access.user = json.access.user || {}; + json.access.user.notes = value; + break; + } + + response = await this.put( + "keys/" + config.apiKey, + JSON.stringify(json), + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + } + else { + let xml; + try { + xml = this.getXMLFromResponse(response); + } + catch (e) { + console.log(response.data); + throw e; + } + let current; + for (let access of xml.getElementsByTagName("access")) { + switch (permission) { + case 'library': + current = parseInt(access.getAttribute('library')); + if (current != value) { + access.setAttribute('library', parseInt(value)); + } + break; + + case 'write': + if (!access.library) { + continue; + } + current = parseInt(access.getAttribute('write')); + if (current != value) { + access.setAttribute('write', parseInt(value)); + } + break; + + case 'notes': + if (!access.library) { + break; + } + current = parseInt(access.getAttribute('notes')); + if (current != value) { + access.setAttribute('notes', parseInt(value)); + } + break; + } + } + + response = await this.put( + "keys/" + config.apiKey, + xml.outterHTML, + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + } + if (response.status != 200) { + console.log(response.data); + throw new Error("PUT returned " + response.status); + } + } + + static async createAnnotationItem(annotationType, data = {}, parentKey, context = false, returnFormat = 'responseJSON') { + let response = await this.get(`items/new?itemType=annotation&annotationType=${annotationType}`); + let json = JSON.parse(response.data); + json.parentItem = parentKey; + if (annotationType === 'highlight') { + json.annotationText = 'This is highlighted text.'; + } + if (data.annotationComment) { + json.annotationComment = data.annotationComment; + } + json.annotationColor = '#ff8c19'; + json.annotationSortIndex = '00015|002431|00000'; + json.annotationPosition = JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }); + + response = await this.postObjects('item', [json]); + return this.handleCreateResponse('item', response, returnFormat, context); + } + + static async createSearch(name, conditions = [], context = null, returnFormat = 'responseJSON') { + if (!conditions || conditions === 'default') { + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'test', + }, + ]; + } + + const json = [ + { + name, + conditions, + }, + ]; + + let response = await this.postObjects('search', json); + return this.handleCreateResponse('search', response, returnFormat, context); + } + + static async getCollectionResponse(keys, context = null, format = false, groupID = false) { + return this.getObjectResponse('collection', keys, context, format, groupID); + } + + static createUnsavedDataObject = async (objectType) => { + let json; + switch (objectType) { + case "collection": + json = { + name: "Test", + }; + break; + + case "item": + // Convert to array + json = await this.getItemTemplate("book"); + break; + + case "search": + json = { + name: "Test", + conditions: [ + { + condition: "title", + operator: "contains", + value: "test", + }, + ], + }; + break; + } + return json; + }; + + static async handleCreateResponse(objectType, response, returnFormat, context = null, groupID = false) { + let uctype = objectType.charAt(0).toUpperCase() + objectType.slice(1); + + if (context) { + Helpers.assert200(response); + } + + if (returnFormat == 'response') { + return response; + } + + let json = this.getJSONFromResponse(response); + + if (returnFormat != 'responseJSON' && Object.keys(json.success).length != 1) { + console.log(json); + throw new Error(uctype + " creation failed"); + } + + if (returnFormat == 'responseJSON') { + return json; + } + + let key = json.success[0]; + + if (returnFormat == 'key') { + return key; + } + + let asResponse = false; + if (/response$/i.test(returnFormat)) { + returnFormat = returnFormat.substring(0, returnFormat.length - 8); + asResponse = true; + } + let responseFunc; + switch (uctype) { + case 'Item': + responseFunc = asResponse ? this.getItemResponse : this.getItem; + break; + case 'Collection': + responseFunc = asResponse ? this.getCollectionResponse : this.getCollection; + break; + case 'Search': + responseFunc = asResponse ? this.getSearchResponse : this.getSearch; + break; + default: + throw Error("Unknown object type"); + } + + if (returnFormat.substring(0, 4) == 'json') { + response = await responseFunc(key, this, 'json', groupID); + if (returnFormat == 'json' || returnFormat == 'jsonResponse') { + return response; + } + if (returnFormat == 'jsonData') { + return response.data; + } + } + + response = await responseFunc(key, this, 'atom', groupID); + + if (returnFormat == 'atom' || returnFormat == 'atomResponse') { + return response; + } + + let xml = this.getXMLFromResponse(response); + let data = this.parseDataFromAtomEntry(xml); + + if (returnFormat == 'data') { + return data; + } + if (returnFormat == 'content') { + return data.content; + } + if (returnFormat == 'atomJSON') { + return JSON.parse(data.content); + } + + throw new Error("Invalid result format '" + returnFormat + "'"); + } + + static async getObjectResponse(objectType, keys, context = null, format = false, groupID = false) { + let objectTypePlural = this.getPluralObjectType(objectType); + + let single = typeof keys === "string"; + + let url = `${objectTypePlural}`; + if (single) { + url += `/${keys}`; + } + url += `?key=${config.apiKey}`; + if (!single) { + url += `&${objectType}Key=${keys.join(',')}&order=${objectType}KeyList`; + } + if (format !== false) { + url += `&format=${format}`; + if (format == 'atom') { + url += '&content=json'; + } + } + let response; + if (groupID) { + response = await this.groupGet(groupID, url); + } + else { + response = await this.userGet(config.userID, url); + } + if (context) { + Helpers.assert200(response); + } + return response; + } + + static async getObject(objectType, keys, context = null, format = false, groupID = false) { + let response = await this.getObjectResponse(objectType, keys, context, format, groupID); + let contentType = response.headers['content-type'][0]; + switch (contentType) { + case 'application/json': + return this.getJSONFromResponse(response); + + case 'application/atom+xml': + return this.getXMLFromResponse(response); + + default: + console.log(response.body); + throw new Error(`Unknown content type '${contentType}'`); + } + } + + //////Response parsing + static getXMLFromResponse(response) { + var result; + try { + const jsdom = new JSDOM(response.data, { contentType: "application/xml", url: "http://localhost/" }); + wgxpath.install(jsdom.window, true); + result = jsdom.window._document; + } + catch (e) { + console.log(response.data); + throw e; + } + return result; + } + + static getJSONFromResponse(response) { + const json = JSON.parse(response.data); + if (json === null) { + console.log(response.data); + throw new Error("JSON response could not be parsed"); + } + return json; + } + + static getFirstSuccessKeyFromResponse(response) { + const json = this.getJSONFromResponse(response); + if (!json.success || json.success.length === 0) { + console.log(response.data); + throw new Error("No success keys found in response"); + } + return json.success[0]; + } + + static parseDataFromAtomEntry(entryXML) { + const key = entryXML.getElementsByTagName('zapi:key')[0]; + const version = entryXML.getElementsByTagName('zapi:version')[0]; + const content = entryXML.getElementsByTagName('content')[0]; + if (content === null) { + console.log(entryXML.outerHTML); + throw new Error("Atom response does not contain "); + } + + return { + key: key ? key.textContent : null, + version: version ? version.textContent : null, + content: content ? content.textContent : null + }; + } + + static getContentFromResponse(response) { + const xml = this.getXMLFromResponse(response); + const data = this.parseDataFromAtomEntry(xml); + return data.content; + } + + static getPluralObjectType(objectType) { + if (objectType === 'search') { + return objectType + "es"; + } + return objectType + "s"; + } +} + +module.exports = API3; diff --git a/tests/remote_js/config/default.json5 b/tests/remote_js/config/default.json5 new file mode 100644 index 00000000..35a8e724 --- /dev/null +++ b/tests/remote_js/config/default.json5 @@ -0,0 +1,40 @@ +{ + "verbose": 0, + "apiURLPrefix": "http://localhost/", + "rootUsername": "", + "rootPassword": "", + "awsRegion": "", + "s3Bucket": "", + "awsAccessKey": "", + "awsSecretKey": "", + "timeout": 30000, + "numOwnedGroups": 3, + "numPublicGroups": 2, + + "userID": 1, + "libraryID": 1, + "username": "testuser", + "password": "letmein", + "displayName": "testuser", + "emailPrimary": "test@test.com", + "emailSecondary": "test@test.com", + "ownedPrivateGroupID": 1, + "ownedPrivateGroupLibraryID": 1, + "ownedPrivateGroupName": "Test Group", + + "userID2": 2, + "username2": "testuser2", + "password2": "letmein2", + "displayName2": "testuser2", + "ownedPrivateGroupID2": 0, + "ownedPrivateGroupLibraryID2": 0, + + "fullTextExtractorSQSUrl" : "", + "isLocalRun": false, + "es": { + "host": "", + "index": "item_fulltext_index", + "type": "item_fulltext" + } + +} diff --git a/tests/remote_js/data/bad_string.xml b/tests/remote_js/data/bad_string.xml new file mode 100644 index 00000000..5cf31734 --- /dev/null +++ b/tests/remote_js/data/bad_string.xml @@ -0,0 +1,6 @@ +<p>&nbsp;</p> +<p style="margin-top: 0.18cm; margin-bottom: 0.18cm; line-height: 100%;" lang="es-ES"><br /><br /></p> +<p style="margin-top: 0.18cm; margin-bottom: 0.18cm; line-height: 100%;" lang="es-ES"><br /><br /></p> +<table border="1" cellspacing="0" cellpadding="7" width="614"> +<colgroup><col width="598"></col> </colgroup> +<p style="margin-top: 0.18cm; margin-bottom: 0.18cm;" lang="en-US"><span style="font-family: Times New Roman,serif;"><span style="font-size: x-large;"><strong>test</strong></span></span></p> diff --git a/tests/remote_js/data/test.html.zip b/tests/remote_js/data/test.html.zip new file mode 100644 index 00000000..67876366 Binary files /dev/null and b/tests/remote_js/data/test.html.zip differ diff --git a/tests/remote_js/data/test.pdf b/tests/remote_js/data/test.pdf new file mode 100644 index 00000000..2fd60208 Binary files /dev/null and b/tests/remote_js/data/test.pdf differ diff --git a/tests/remote_js/full-text-extractor b/tests/remote_js/full-text-extractor new file mode 160000 index 00000000..00932c40 --- /dev/null +++ b/tests/remote_js/full-text-extractor @@ -0,0 +1 @@ +Subproject commit 00932c40b5b39da1b12fa3b036a95edeec1ad6af diff --git a/tests/remote_js/full-text-indexer b/tests/remote_js/full-text-indexer new file mode 160000 index 00000000..d9624695 --- /dev/null +++ b/tests/remote_js/full-text-indexer @@ -0,0 +1 @@ +Subproject commit d962469501641c196c9a356ef8e02fe6a5aad772 diff --git a/tests/remote_js/groupsSetup.js b/tests/remote_js/groupsSetup.js new file mode 100644 index 00000000..5e17d6a4 --- /dev/null +++ b/tests/remote_js/groupsSetup.js @@ -0,0 +1,100 @@ + +var config = require('config'); +const API3 = require('./api3.js'); + +const resetGroups = async () => { + let resetGroups = true; + + let response = await API3.superGet( + `users/${config.userID}/groups` + ); + let groups = await API3.getJSONFromResponse(response); + config.ownedPublicGroupID = null; + config.ownedPublicNoAnonymousGroupID = null; + config.ownedPrivateGroupID = null; + config.ownedPrivateGroupName = 'Private Test Group'; + config.ownedPrivateGroupID2 = null; + let toDelete = []; + for (let group of groups) { + let data = group.data; + let id = data.id; + let type = data.type; + let owner = data.owner; + let libraryReading = data.libraryReading; + + if (resetGroups) { + toDelete.push(id); + continue; + } + + if (!config.ownedPublicGroupID + && type == 'PublicOpen' + && owner == config.userID + && libraryReading == 'all') { + config.ownedPublicGroupID = id; + } + else if (!config.ownedPublicNoAnonymousGroupID + && type == 'PublicClosed' + && owner == config.userID + && libraryReading == 'members') { + config.ownedPublicNoAnonymousGroupID = id; + } + else if (type == 'Private' && owner == config.userID && data.name == config.ownedPrivateGroupName) { + config.ownedPrivateGroupID = id; + } + else if (type == 'Private' && owner == config.userID2) { + config.ownedPrivateGroupID2 = id; + } + else { + toDelete.push(id); + } + } + + if (!config.ownedPublicGroupID) { + config.ownedPublicGroupID = await API3.createGroup({ + owner: config.userID, + type: 'PublicOpen', + libraryReading: 'all' + }); + } + if (!config.ownedPublicNoAnonymousGroupID) { + config.ownedPublicNoAnonymousGroupID = await API3.createGroup({ + owner: config.userID, + type: 'PublicClosed', + libraryReading: 'members' + }); + } + if (!config.ownedPrivateGroupID) { + config.ownedPrivateGroupID = await API3.createGroup({ + owner: config.userID, + name: "Private Test Group", + type: 'Private', + libraryReading: 'members', + fileEditing: 'members', + members: [ + config.userID2 + ] + }); + } + if (!config.ownedPrivateGroupID2) { + config.ownedPrivateGroupID2 = await API3.createGroup({ + owner: config.userID2, + type: 'Private', + libraryReading: 'members', + fileEditing: 'members' + }); + } + for (let groupID of toDelete) { + await API3.deleteGroup(groupID); + } + + for (let group of groups) { + if (!toDelete.includes(group.id)) { + await API3.groupClear(group.id); + } + } +}; + +module.exports = { + resetGroups +}; diff --git a/tests/remote_js/helpers2.js b/tests/remote_js/helpers2.js new file mode 100644 index 00000000..c1cd3931 --- /dev/null +++ b/tests/remote_js/helpers2.js @@ -0,0 +1,240 @@ +const { JSDOM } = require("jsdom"); +const chai = require('chai'); +const assert = chai.assert; +const crypto = require('crypto'); +const fs = require('fs'); + +class Helpers2 { + static uniqueToken = () => { + const id = crypto.randomBytes(16).toString("hex"); + const hash = crypto.createHash('md5').update(id).digest('hex'); + return hash; + }; + + static uniqueID = (count = 8) => { + const chars = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Z']; + let result = ""; + for (let i = 0; i < count; i++) { + result += chars[crypto.randomInt(chars.length)]; + } + return result; + }; + + static md5 = (str) => { + return crypto.createHash('md5').update(str).digest('hex'); + }; + + static md5File = (fileName) => { + const data = fs.readFileSync(fileName); + return crypto.createHash('md5').update(data).digest('hex'); + }; + + static implodeParams = (params, exclude = []) => { + let parts = []; + for (const [key, value] of Object.entries(params)) { + if (!exclude.includes(key)) { + parts.push(key + "=" + encodeURIComponent(value)); + } + } + return parts.join("&"); + }; + + static namespaceResolver = (prefix) => { + let ns = { + atom: 'http://www.w3.org/2005/Atom', + zapi: 'http://zotero.org/ns/api', + zxfer: 'http://zotero.org/ns/transfer', + html: 'http://www.w3.org/1999/xhtml' + }; + return ns[prefix] || null; + }; + + static xpathEval = (document, xpath, returnHtml = false, multiple = false, element = null) => { + const xpathData = document.evaluate(xpath, (element || document), this.namespaceResolver, 5, null); + if (!multiple && xpathData.snapshotLength != 1) { + throw new Error("No single xpath value fetched"); + } + var node; + var result = []; + do { + node = xpathData.iterateNext(); + if (node) { + result.push(node); + } + } while (node); + + if (returnHtml) { + return multiple ? result : result[0]; + } + + return multiple ? result.map(el => el.innerHTML) : result[0].innerHTML; + }; + + static assertRegExp(exp, val) { + if (typeof exp == "string") { + exp = new RegExp(exp); + } + if (!exp.test(val)) { + throw new Error(`${val} does not match regular expression`); + } + } + + static assertXMLEqual = (one, two) => { + const contentDom = new JSDOM(one); + const expectedDom = new JSDOM(two); + assert.equal(contentDom.window.document.innerHTML, expectedDom.window.document.innerHTML); + }; + + static assertStatusCode = (response, expectedCode, message) => { + try { + assert.equal(response.status, expectedCode); + if (message) { + assert.equal(response.data, message); + } + } + catch (e) { + console.log(response.data); + throw e; + } + }; + + static assertStatusForObject = (response, status, recordId, httpCode, message) => { + let body = response; + if (response.data) { + body = response.data; + } + try { + body = JSON.parse(body); + } + catch (e) { } + assert.include(['unchanged', 'failed', 'success'], status); + + try { + //Make sure the recordId is in the right category - unchanged, failed, success + assert.property(body[status], recordId); + if (httpCode) { + assert.equal(body[status][recordId].code, httpCode); + } + if (message) { + assert.equal(body[status][recordId].message, message); + } + } + catch (e) { + console.log(response.data); + throw e; + } + }; + + static assertNumResults = (response, expectedResults) => { + const doc = new JSDOM(response.data, { url: "http://localhost/" }); + const entries = this.xpathEval(doc.window.document, "//entry", false, true); + assert.equal(entries.length, expectedResults); + }; + + static assertTotalResults = (response, expectedResults) => { + const doc = new JSDOM(response.data, { url: "http://localhost/" }); + const totalResults = this.xpathEval(doc.window.document, "//zapi:totalResults", false, true); + assert.equal(parseInt(totalResults[0]), expectedResults); + }; + + static assertContentType = (response, contentType) => { + assert.include(response?.headers['content-type'], contentType.toLowerCase()); + }; + + + //Assert codes + static assert200 = (response) => { + this.assertStatusCode(response, 200); + }; + + static assert201 = (response) => { + this.assertStatusCode(response, 201); + }; + + static assert204 = (response) => { + this.assertStatusCode(response, 204); + }; + + static assert300 = (response) => { + this.assertStatusCode(response, 300); + }; + + static assert302 = (response) => { + this.assertStatusCode(response, 302); + }; + + static assert400 = (response, message) => { + this.assertStatusCode(response, 400, message); + }; + + static assert401 = (response) => { + this.assertStatusCode(response, 401); + }; + + static assert403 = (response) => { + this.assertStatusCode(response, 403); + }; + + static assert412 = (response) => { + this.assertStatusCode(response, 412); + }; + + static assert428 = (response) => { + this.assertStatusCode(response, 428); + }; + + static assert404 = (response) => { + this.assertStatusCode(response, 404); + }; + + static assert405 = (response) => { + this.assertStatusCode(response, 405); + }; + + static assert500 = (response) => { + this.assertStatusCode(response, 500); + }; + + static assert400ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 400, message); + }; + + static assert200ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'success', index, message); + }; + + static assert404ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 404, message); + }; + + static assert409ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 409, message); + }; + + static assert412ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 412, message); + }; + + static assert413ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 413, message); + }; + + static assert428ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 428, message); + }; + + static assertUnchangedForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'unchanged', index, null, message); + }; + + // Methods to help during conversion + static assertEquals = (one, two) => { + assert.equal(two, one); + }; + + static assertCount = (count, object) => { + assert.lengthOf(Object.keys(object), count); + }; +} + +module.exports = Helpers2; diff --git a/tests/remote_js/helpers3.js b/tests/remote_js/helpers3.js new file mode 100644 index 00000000..34531964 --- /dev/null +++ b/tests/remote_js/helpers3.js @@ -0,0 +1,333 @@ +const { JSDOM } = require("jsdom"); +const chai = require('chai'); +const assert = chai.assert; +const crypto = require('crypto'); +const fs = require('fs'); + +class Helpers3 { + static notificationHeader = 'zotero-debug-notifications'; + + + static uniqueToken = () => { + const id = crypto.randomBytes(16).toString("hex"); + const hash = crypto.createHash('md5').update(id).digest('hex'); + return hash; + }; + + static uniqueID = (count = 8) => { + const chars = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Z']; + let result = ""; + for (let i = 0; i < count; i++) { + result += chars[crypto.randomInt(chars.length)]; + } + return result; + }; + + static namespaceResolver = (prefix) => { + let ns = { + atom: 'http://www.w3.org/2005/Atom', + zapi: 'http://zotero.org/ns/api', + zxfer: 'http://zotero.org/ns/transfer', + html: 'http://www.w3.org/1999/xhtml' + }; + return ns[prefix] || null; + }; + + static xpathEval = (document, xpath, returnHtml = false, multiple = false, element = null) => { + const xpathData = document.evaluate(xpath, (element || document), this.namespaceResolver, 5, null); + if (!multiple && xpathData.snapshotLength != 1) { + throw new Error("No single xpath value fetched"); + } + var node; + var result = []; + do { + node = xpathData.iterateNext(); + if (node) { + result.push(node); + } + } while (node); + + if (returnHtml) { + return multiple ? result : result[0]; + } + + return multiple ? result.map(el => el.innerHTML) : result[0].innerHTML; + }; + + static assertRegExp(exp, val) { + if (typeof exp == "string") { + exp = new RegExp(exp); + } + if (!exp.test(val)) { + throw new Error(`${val} does not match regular expression`); + } + } + + static assertXMLEqual = (one, two) => { + const contentDom = new JSDOM(one); + const expectedDom = new JSDOM(two); + assert.equal(contentDom.window.document.innerHTML, expectedDom.window.document.innerHTML); + }; + + static assertStatusCode = (response, expectedCode, message) => { + try { + assert.equal(response.status, expectedCode); + if (message) { + assert.equal(response.data, message); + } + } + catch (e) { + console.log(response.data); + throw e; + } + }; + + static assertCompression = (response) => { + assert.equal(response.headers['content-encoding'][0], 'gzip'); + }; + + static assertNoCompression = (response) => { + assert.notOk(response.headers['content-encoding']); + }; + + static assertContentLength = (response, length) => { + assert.equal(response.headers['content-length'] || 0, length); + }; + + static assertStatusForObject = (response, status, recordId, httpCode, message) => { + let body = response; + if (response.data) { + body = response.data; + } + try { + body = JSON.parse(body); + } + catch (e) { } + assert.include(['unchanged', 'failed', 'success'], status); + + try { + //Make sure the recordId is in the right category - unchanged, failed, success + assert.property(body[status], recordId); + if (httpCode) { + assert.equal(body[status][recordId].code, httpCode); + } + if (message) { + assert.equal(body[status][recordId].message, message); + } + } + catch (e) { + console.log(response.data); + throw e; + } + }; + + static assertNumResults = (response, expectedResults) => { + const contentType = response.headers['content-type'][0]; + if (contentType == 'application/json') { + const json = JSON.parse(response.data); + if (Array.isArray(json)) { + assert.equal(json.length, expectedResults); + return; + } + assert.lengthOf(Object.keys(json), expectedResults); + } + else if (contentType.includes('text/plain')) { + const rows = response.data.trim().split("\n"); + assert.lengthOf(rows, expectedResults); + } + else if (contentType == 'application/x-bibtex') { + let matched = response.data.match(/^@[a-z]+{/gm); + assert.lengthOf(matched, expectedResults); + } + else if (contentType == 'application/atom+xml') { + const doc = new JSDOM(response.data, { url: "http://localhost/" }); + const entries = this.xpathEval(doc.window.document, "//entry", false, true); + assert.equal(entries.length, expectedResults); + } + else { + throw new Error(`Unknonw content type" ${contentType}`); + } + }; + + static assertTotalResults(response, expectedCount) { + const totalResults = parseInt(response.headers['total-results'][0]); + assert.isNumber(totalResults); + assert.equal(totalResults, expectedCount); + } + + static assertContentType = (response, contentType) => { + assert.include(response?.headers['content-type'], contentType.toLowerCase()); + }; + + + //Assert codes + static assert200 = (response) => { + this.assertStatusCode(response, 200); + }; + + static assert201 = (response) => { + this.assertStatusCode(response, 201); + }; + + static assert204 = (response) => { + this.assertStatusCode(response, 204); + }; + + static assert300 = (response) => { + this.assertStatusCode(response, 300); + }; + + static assert302 = (response) => { + this.assertStatusCode(response, 302); + }; + + static assert400 = (response, message) => { + this.assertStatusCode(response, 400, message); + }; + + static assert401 = (response) => { + this.assertStatusCode(response, 401); + }; + + static assert403 = (response) => { + this.assertStatusCode(response, 403); + }; + + static assert412 = (response) => { + this.assertStatusCode(response, 412); + }; + + static assert428 = (response) => { + this.assertStatusCode(response, 428); + }; + + static assert404 = (response) => { + this.assertStatusCode(response, 404); + }; + + static assert405 = (response) => { + this.assertStatusCode(response, 405); + }; + + static assert500 = (response) => { + this.assertStatusCode(response, 500); + }; + + static assert400ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 400, message); + }; + + static assert200ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'success', index, message); + }; + + static assert404ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 404, message); + }; + + static assert409ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 409, message); + }; + + static assert412ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 412, message); + }; + + static assert413ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 413, message); + }; + + static assert428ForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'failed', index, 428, message); + }; + + static assertUnchangedForObject = (response, { index = 0, message = null } = {}) => { + this.assertStatusForObject(response, 'unchanged', index, null, message); + }; + + // Methods to help during conversion + static assertEquals = (one, two) => { + assert.equal(two, one); + }; + + static assertCount = (count, object) => { + assert.lengthOf(Object.keys(object), count); + }; + + static assertNoResults(response) { + this.assertTotalResults(response, 0); + + const contentType = response.headers['content-type'][0]; + if (contentType == 'application/json') { + const json = JSON.parse(response.data); + assert.lengthOf(Object.keys(json), 0); + } + else if (contentType == 'application/atom+xml') { + const xml = new JSDOM(response.data, { url: "http://localhost/" }); + const entries = xml.window.document.getElementsByTagName('entry'); + assert.equal(entries.length, 0); + } + else { + throw new Error(`Unknown content type ${contentType}`); + } + } + + static md5 = (str) => { + return crypto.createHash('md5').update(str).digest('hex'); + }; + + static md5File = (fileName) => { + const data = fs.readFileSync(fileName); + return crypto.createHash('md5').update(data).digest('hex'); + }; + + static getRandomUnicodeString = function () { + const rand = crypto.randomInt(10, 100); + return "Âéìøü 这是一个测试。 " + this.uniqueID(rand); + }; + + static implodeParams = (params, exclude = []) => { + let parts = []; + for (const [key, value] of Object.entries(params)) { + if (!exclude.includes(key)) { + parts.push(key + "=" + encodeURIComponent(value)); + } + } + return parts.join("&"); + }; + + static assertHasNotification(notification, response) { + let header = response.headers[this.notificationHeader][0]; + assert.ok(header); + + // Header contains a Base64-encode array of encoded JSON notifications + try { + let notifications = JSON.parse(Buffer.from(header, 'base64')).map(n => JSON.parse(n)); + assert.deepInclude(notifications, notification); + } + catch (e) { + console.log("\nHeader: " + Buffer.from(header, 'base64') + "\n"); + throw e; + } + } + + static assertNotificationCount(expected, response) { + let headerArr = response.headers[this.notificationHeader] || []; + let header = headerArr.length > 0 ? headerArr[0] : ""; + try { + if (expected === 0) { + assert.lengthOf(headerArr, 0); + } + else { + assert.ok(header); + const headerJSON = JSON.parse(Buffer.from(header, 'base64')); + this.assertCount(expected, headerJSON); + } + } + catch (e) { + console.log("\nHeader: " + JSON.parse(Buffer.from(header, 'base64')) + "\n"); + throw e; + } + } +} +module.exports = Helpers3; diff --git a/tests/remote_js/httpHandler.js b/tests/remote_js/httpHandler.js new file mode 100644 index 00000000..c0894651 --- /dev/null +++ b/tests/remote_js/httpHandler.js @@ -0,0 +1,77 @@ +const fetch = require('node-fetch'); +var config = require('config'); + +class HTTP { + static verbose = config.verbose; + + static async request(method, url, headers = {}, data = {}, auth = false) { + let options = { + method: method, + headers: headers, + follow: 0, + redirect: 'manual', + body: ["POST", "PUT", "PATCH", "DELETE"].includes(method) ? data : null + }; + + if (auth) { + options.headers.Authorization = 'Basic ' + Buffer.from(auth.username + ':' + auth.password).toString('base64'); + } + + if (config.verbose >= 1) { + console.log(`\n${method} ${url}\n`); + } + + //Hardcoded for running tests against containers + const localIPRegex = new RegExp("172.16.0.[0-9][0-9]"); + if (url.match(localIPRegex)) { + url = url.replace(localIPRegex, 'localhost'); + } + + let response = await fetch(url, options); + + + // Fetch doesn't automatically parse the response body, so we have to do that manually + let responseData = await response.text(); + + if (HTTP.verbose >= 2) { + console.log(`\n\n${responseData}\n`); + } + + // Return the response status, headers, and data in a format similar to Axios + return { + status: response.status, + headers: response.headers.raw(), + data: responseData + }; + } + + static get(url, headers = {}, auth = false) { + return this.request('GET', url, headers, {}, auth); + } + + static post(url, data = {}, headers = {}, auth = false) { + return this.request('POST', url, headers, data, auth); + } + + static put(url, data = {}, headers = {}, auth = false) { + return this.request('PUT', url, headers, data, auth); + } + + static patch(url, data = {}, headers = {}, auth = false) { + return this.request('PATCH', url, headers, data, auth); + } + + static head(url, headers = {}, auth = false) { + return this.request('HEAD', url, headers, {}, auth); + } + + static options(url, headers = {}, auth = false) { + return this.request('OPTIONS', url, headers, {}, auth); + } + + static delete(url, headers = {}, auth = false) { + return this.request('DELETE', url, headers, "", auth); + } +} + +module.exports = HTTP; diff --git a/tests/remote_js/package-lock.json b/tests/remote_js/package-lock.json new file mode 100644 index 00000000..78f42947 --- /dev/null +++ b/tests/remote_js/package-lock.json @@ -0,0 +1,3876 @@ +{ + "name": "remote_js", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@aws-sdk/client-s3": "^3.338.0", + "@aws-sdk/client-sqs": "^3.348.0", + "config": "^3.3.9", + "jsdom": "^22.0.0", + "jszip": "^3.10.1", + "node-fetch": "^2.6.7", + "wgxpath": "^1.2.0" + }, + "devDependencies": { + "@zotero/eslint-config": "^1.0.7", + "chai": "^4.3.7", + "mocha": "^10.2.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", + "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/crc32c": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-3.0.0.tgz", + "integrity": "sha512-ENNPPManmnVJ4BTXlOjAgD7URidbAznURqD0KvfREyc4o20DPYdEldU1f5cQ7Jbj0CJJSPaMIk/9ZshdB3210w==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32c/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/ie11-detection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", + "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-3.0.0.tgz", + "integrity": "sha512-NJth5c997GLHs6nOYTzFKTbYdMNA6/1XlKVgnZoaZcQ7z7UJlOgj2JdbHE8tiYLS3fzXNCguct77SPGat2raSw==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", + "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", + "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", + "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", + "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/abort-controller": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.347.0.tgz", + "integrity": "sha512-P/2qE6ntYEmYG4Ez535nJWZbXqgbkJx8CMz7ChEuEg3Gp3dvVYEKg+iEUEvlqQ2U5dWP5J3ehw5po9t86IsVPQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/chunked-blob-reader": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader/-/chunked-blob-reader-3.310.0.tgz", + "integrity": "sha512-CrJS3exo4mWaLnWxfCH+w88Ou0IcAZSIkk4QbmxiHl/5Dq705OLoxf4385MVyExpqpeVJYOYQ2WaD8i/pQZ2fg==", + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.348.0.tgz", + "integrity": "sha512-19ShUJL/Kqol4pW2S6axD85oL2JIh91ctUgqPEuu5BzGyEgq5s+HP/DDNzcdsTKl7gfCfaIULf01yWU6RwY1EA==", + "dependencies": { + "@aws-crypto/sha1-browser": "3.0.0", + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.348.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-node": "3.348.0", + "@aws-sdk/eventstream-serde-browser": "3.347.0", + "@aws-sdk/eventstream-serde-config-resolver": "3.347.0", + "@aws-sdk/eventstream-serde-node": "3.347.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-blob-browser": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/hash-stream-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/md5-js": "3.347.0", + "@aws-sdk/middleware-bucket-endpoint": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-expect-continue": "3.347.0", + "@aws-sdk/middleware-flexible-checksums": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-location-constraint": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-sdk-s3": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/middleware-ssec": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.348.0", + "@aws-sdk/signature-v4-multi-region": "3.347.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-stream-browser": "3.347.0", + "@aws-sdk/util-stream-node": "3.348.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@aws-sdk/util-waiter": "3.347.0", + "@aws-sdk/xml-builder": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "fast-xml-parser": "4.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.348.0.tgz", + "integrity": "sha512-Rglio22q7LpFGcjz3YbdOG+hNEd9Ykuw1aVHA5WQtT5BSxheYPtNv2XQunpvNrXssLZYcQWK4lab450aIfjtFg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.348.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-node": "3.348.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/md5-js": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-sdk-sqs": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.348.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "fast-xml-parser": "4.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.348.0.tgz", + "integrity": "sha512-5S23gVKBl0fhZ96RD8LdPhMKeh8E5fmebyZxMNZuWliSXz++Q9ZCrwPwQbkks3duPOTcKKobs3IoqP82HoXMvQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.348.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.348.0.tgz", + "integrity": "sha512-tvHpcycx4EALvk38I9rAOdPeHvBDezqIB4lrE7AvnOJljlvCcdQ2gXa9GDrwrM7zuYBIZMBRE/njTMrCwoOdAA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.348.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.348.0.tgz", + "integrity": "sha512-4iaQlWAOHMEF4xjR/FB/ws3aUjXjJHwbsIcqbdYAxsKijDYYTZYCPc/gM0NE1yi28qlNYNhMzHipe5xTYbU2Eg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-node": "3.348.0", + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/hash-node": "3.347.0", + "@aws-sdk/invalid-dependency": "3.347.0", + "@aws-sdk/middleware-content-length": "3.347.0", + "@aws-sdk/middleware-endpoint": "3.347.0", + "@aws-sdk/middleware-host-header": "3.347.0", + "@aws-sdk/middleware-logger": "3.347.0", + "@aws-sdk/middleware-recursion-detection": "3.347.0", + "@aws-sdk/middleware-retry": "3.347.0", + "@aws-sdk/middleware-sdk-sts": "3.347.0", + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/middleware-user-agent": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/node-http-handler": "3.348.0", + "@aws-sdk/smithy-client": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-body-length-browser": "3.310.0", + "@aws-sdk/util-body-length-node": "3.310.0", + "@aws-sdk/util-defaults-mode-browser": "3.347.0", + "@aws-sdk/util-defaults-mode-node": "3.347.0", + "@aws-sdk/util-endpoints": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "@aws-sdk/util-user-agent-browser": "3.347.0", + "@aws-sdk/util-user-agent-node": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "@smithy/protocol-http": "^1.0.1", + "@smithy/types": "^1.0.0", + "fast-xml-parser": "4.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/config-resolver": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.347.0.tgz", + "integrity": "sha512-2ja+Sf/VnUO7IQ3nKbDQ5aumYKKJUaTm/BuVJ29wNho8wYHfuf7wHZV0pDTkB8RF5SH7IpHap7zpZAj39Iq+EA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-config-provider": "3.310.0", + "@aws-sdk/util-middleware": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.347.0.tgz", + "integrity": "sha512-UnEM+LKGpXKzw/1WvYEQsC6Wj9PupYZdQOE+e2Dgy2dqk/pVFy4WueRtFXYDT2B41ppv3drdXUuKZRIDVqIgNQ==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-imds": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.347.0.tgz", + "integrity": "sha512-7scCy/DCDRLIhlqTxff97LQWDnRwRXji3bxxMg+xWOTTaJe7PWx+etGSbBWaL42vsBHFShQjSLvJryEgoBktpw==", + "dependencies": { + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.348.0.tgz", + "integrity": "sha512-0IEH5mH/cz2iLyr/+pSa3sCsQcGADiLSEn6yivsXdfz1zDqBiv+ffDoL0+Pvnp+TKf8sA6OlX8PgoMoEBvBdKw==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.347.0", + "@aws-sdk/credential-provider-imds": "3.347.0", + "@aws-sdk/credential-provider-process": "3.347.0", + "@aws-sdk/credential-provider-sso": "3.348.0", + "@aws-sdk/credential-provider-web-identity": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.348.0.tgz", + "integrity": "sha512-ngRWphm9e36i58KqVi7Z8WOub+k0cSl+JZaAmgfFm0+dsfBG5uheo598OeiwWV0DqlilvaQZFaMVQgG2SX/tHg==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.347.0", + "@aws-sdk/credential-provider-imds": "3.347.0", + "@aws-sdk/credential-provider-ini": "3.348.0", + "@aws-sdk/credential-provider-process": "3.347.0", + "@aws-sdk/credential-provider-sso": "3.348.0", + "@aws-sdk/credential-provider-web-identity": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.347.0.tgz", + "integrity": "sha512-yl1z4MsaBdXd4GQ2halIvYds23S67kElyOwz7g8kaQ4kHj+UoYWxz3JVW/DGusM6XmQ9/F67utBrUVA0uhQYyw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.348.0.tgz", + "integrity": "sha512-5cQao705376KgGkLv9xgkQ3T5H7KdNddWuyoH2wDcrHd1BA2Lnrell3Yyh7R6jQeV7uCQE/z0ugUOKhDqNKIqQ==", + "dependencies": { + "@aws-sdk/client-sso": "3.348.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/token-providers": "3.348.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.347.0.tgz", + "integrity": "sha512-DxoTlVK8lXjS1zVphtz/Ab+jkN/IZor9d6pP2GjJHNoAIIzXfRwwj5C8vr4eTayx/5VJ7GRP91J8GJ2cKly8Qw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-codec": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-codec/-/eventstream-codec-3.347.0.tgz", + "integrity": "sha512-61q+SyspjsaQ4sdgjizMyRgVph2CiW4aAtfpoH69EJFJfTxTR/OqnZ9Jx/3YiYi0ksrvDenJddYodfWWJqD8/w==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/eventstream-serde-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.347.0.tgz", + "integrity": "sha512-9BLVTHWgpiTo/hl+k7qt7E9iYu43zVwJN+4TEwA9ZZB3p12068t1Hay6HgCcgJC3+LWMtw/OhvypV6vQAG4UBg==", + "dependencies": { + "@aws-sdk/eventstream-serde-universal": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-serde-config-resolver": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.347.0.tgz", + "integrity": "sha512-RcXQbNVq0PFmDqfn6+MnjCUWbbobcYVxpimaF6pMDav04o6Mcle+G2Hrefp5NlFr/lZbHW2eUKYsp1sXPaxVlQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-serde-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-node/-/eventstream-serde-node-3.347.0.tgz", + "integrity": "sha512-pgQCWH0PkHjcHs04JE7FoGAD3Ww45ffV8Op0MSLUhg9OpGa6EDoO3EOpWi9l/TALtH4f0KRV35PVyUyHJ/wEkA==", + "dependencies": { + "@aws-sdk/eventstream-serde-universal": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-serde-universal": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-universal/-/eventstream-serde-universal-3.347.0.tgz", + "integrity": "sha512-4wWj6bz6lOyDIO/dCCjwaLwRz648xzQQnf89R29sLoEqvAPP5XOB7HL+uFaQ/f5tPNh49gL6huNFSVwDm62n4Q==", + "dependencies": { + "@aws-sdk/eventstream-codec": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/fetch-http-handler": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.347.0.tgz", + "integrity": "sha512-sQ5P7ivY8//7wdxfA76LT1sF6V2Tyyz1qF6xXf9sihPN5Q1Y65c+SKpMzXyFSPqWZ82+SQQuDliYZouVyS6kQQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/querystring-builder": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/hash-blob-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/hash-blob-browser/-/hash-blob-browser-3.347.0.tgz", + "integrity": "sha512-RxgstIldLsdJKN5UHUwSI9PMiatr0xKmKxS4+tnWZ1/OOg6wuWqqpDpWdNOVSJSpxpUaP6kRrvG5Yo5ZevoTXw==", + "dependencies": { + "@aws-sdk/chunked-blob-reader": "3.310.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/hash-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.347.0.tgz", + "integrity": "sha512-96+ml/4EaUaVpzBdOLGOxdoXOjkPgkoJp/0i1fxOJEvl8wdAQSwc3IugVK9wZkCxy2DlENtgOe6DfIOhfffm/g==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-buffer-from": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/hash-stream-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/hash-stream-node/-/hash-stream-node-3.347.0.tgz", + "integrity": "sha512-tOBfcvELyt1GVuAlQ4d0mvm3QxoSSmvhH15SWIubM9RP4JWytBVzaFAn/aC02DBAWyvp0acMZ5J+47mxrWJElg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/invalid-dependency": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.347.0.tgz", + "integrity": "sha512-8imQcwLwqZ/wTJXZqzXT9pGLIksTRckhGLZaXT60tiBOPKuerTsus2L59UstLs5LP8TKaVZKFFSsjRIn9dQdmQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/is-array-buffer": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.310.0.tgz", + "integrity": "sha512-urnbcCR+h9NWUnmOtet/s4ghvzsidFmspfhYaHAmSRdy9yDjdjBJMFjjsn85A1ODUktztm+cVncXjQ38WCMjMQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/md5-js": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/md5-js/-/md5-js-3.347.0.tgz", + "integrity": "sha512-mChE+7DByTY9H4cQ6fnWp2x5jf8e6OZN+AdLp6WQ+W99z35zBeqBxVmgm8ziJwkMIrkSTv9j3Y7T9Ve3RIcSfg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.347.0.tgz", + "integrity": "sha512-i9n4ylkGmGvizVcTfN4L+oN10OCL2DKvyMa4cCAVE1TJrsnaE0g7IOOyJGUS8p5KJYQrKVR7kcsa2L1S0VeEcA==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-arn-parser": "3.310.0", + "@aws-sdk/util-config-provider": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-content-length": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.347.0.tgz", + "integrity": "sha512-i4qtWTDImMaDUtwKQPbaZpXsReiwiBomM1cWymCU4bhz81HL01oIxOxOBuiM+3NlDoCSPr3KI6txZSz/8cqXCQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-endpoint": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.347.0.tgz", + "integrity": "sha512-unF0c6dMaUL1ffU+37Ugty43DgMnzPWXr/Jup/8GbK5fzzWT5NQq6dj9KHPubMbWeEjQbmczvhv25JuJdK8gNQ==", + "dependencies": { + "@aws-sdk/middleware-serde": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/url-parser": "3.347.0", + "@aws-sdk/util-middleware": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.347.0.tgz", + "integrity": "sha512-95M1unD1ENL0tx35dfyenSfx0QuXBSKtOi/qJja6LfX5771C5fm5ZTOrsrzPFJvRg/wj8pCOVWRZk+d5+jvfOQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.347.0.tgz", + "integrity": "sha512-Pda7VMAIyeHw9nMp29rxdFft3EF4KP/tz/vLB6bqVoBNbLujo5rxn3SGOgStgIz7fuMLQQfoWIsmvxUm+Fp+Dw==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@aws-crypto/crc32c": "3.0.0", + "@aws-sdk/is-array-buffer": "3.310.0", + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.347.0.tgz", + "integrity": "sha512-kpKmR9OvMlnReqp5sKcJkozbj1wmlblbVSbnQAIkzeQj2xD5dnVR3Nn2ogQKxSmU1Fv7dEroBtrruJ1o3fY38A==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.347.0.tgz", + "integrity": "sha512-x5fcEV7q8fQ0OmUO+cLhN5iPqGoLWtC3+aKHIfRRb2BpOO1khyc1FKzsIAdeQz2hfktq4j+WsrmcPvFKv51pSg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.347.0.tgz", + "integrity": "sha512-NYC+Id5UCkVn+3P1t/YtmHt75uED06vwaKyxDy0UmB2K66PZLVtwWbLpVWrhbroaw1bvUHYcRyQ9NIfnVcXQjA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.347.0.tgz", + "integrity": "sha512-qfnSvkFKCAMjMHR31NdsT0gv5Sq/ZHTUD4yQsSLpbVQ6iYAS834lrzXt41iyEHt57Y514uG7F/Xfvude3u4icQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-retry": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.347.0.tgz", + "integrity": "sha512-CpdM+8dCSbX96agy4FCzOfzDmhNnGBM/pxrgIVLm5nkYTLuXp/d7ubpFEUHULr+4hCd5wakHotMt7yO29NFaVw==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/service-error-classification": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-middleware": "3.347.0", + "@aws-sdk/util-retry": "3.347.0", + "tslib": "^2.5.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.347.0.tgz", + "integrity": "sha512-TLr92+HMvamrhJJ0VDhA/PiUh4rTNQz38B9dB9ikohTaRgm+duP+mRiIv16tNPZPGl8v82Thn7Ogk2qPByNDtg==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-arn-parser": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.347.0.tgz", + "integrity": "sha512-TSBTQoOVe9cDm9am4NOov1YZxbQ3LPBl7Ex0jblDFgUXqE9kNU3Kx/yc8edOLcq+5AFrgqT0NFD7pwFlQPh3KQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sts": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.347.0.tgz", + "integrity": "sha512-38LJ0bkIoVF3W97x6Jyyou72YV9Cfbml4OaDEdnrCOo0EssNZM5d7RhjMvQDwww7/3OBY/BzeOcZKfJlkYUXGw==", + "dependencies": { + "@aws-sdk/middleware-signing": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-serde": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.347.0.tgz", + "integrity": "sha512-x5Foi7jRbVJXDu9bHfyCbhYDH5pKK+31MmsSJ3k8rY8keXLBxm2XEEg/AIoV9/TUF9EeVvZ7F1/RmMpJnWQsEg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-signing": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.347.0.tgz", + "integrity": "sha512-zVBF/4MGKnvhAE/J+oAL/VAehiyv+trs2dqSQXwHou9j8eA8Vm8HS2NdOwpkZQchIxTuwFlqSusDuPEdYFbvGw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/signature-v4": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-middleware": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.347.0.tgz", + "integrity": "sha512-467VEi2elPmUGcHAgTmzhguZ3lwTpwK+3s+pk312uZtVsS9rP1MAknYhpS3ZvssiqBUVPx8m29cLcC6Tx5nOJg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-stack": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.347.0.tgz", + "integrity": "sha512-Izidg4rqtYMcKuvn2UzgEpPLSmyd8ub9+LQ2oIzG3mpIzCBITq7wp40jN1iNkMg+X6KEnX9vdMJIYZsPYMCYuQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.347.0.tgz", + "integrity": "sha512-wJbGN3OE1/daVCrwk49whhIr9E0j1N4gWwN/wi4WuyYIA+5lMUfVp0aGIOvZR+878DxuFz2hQ4XcZVT4K2WvQw==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-endpoints": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/node-config-provider": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.347.0.tgz", + "integrity": "sha512-faU93d3+5uTTUcotGgMXF+sJVFjrKh+ufW+CzYKT4yUHammyaIab/IbTPWy2hIolcEGtuPeVoxXw8TXbkh/tuw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/node-http-handler": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.348.0.tgz", + "integrity": "sha512-wxdgc4tO5F6lN4wHr0CZ4TyIjDW/ORp4SJZdWYNs2L5J7+/SwqgJY2lxRlGi0i7Md+apAdE3sT3ukVQ/9pVfPg==", + "dependencies": { + "@aws-sdk/abort-controller": "3.347.0", + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/querystring-builder": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/property-provider": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.347.0.tgz", + "integrity": "sha512-t3nJ8CYPLKAF2v9nIHOHOlF0CviQbTvbFc2L4a+A+EVd/rM4PzL3+3n8ZJsr0h7f6uD04+b5YRFgKgnaqLXlEg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/protocol-http": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.347.0.tgz", + "integrity": "sha512-2YdBhc02Wvy03YjhGwUxF0UQgrPWEy8Iq75pfS42N+/0B/+eWX1aQgfjFxIpLg7YSjT5eKtYOQGlYd4MFTgj9g==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/querystring-builder": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.347.0.tgz", + "integrity": "sha512-phtKTe6FXoV02MoPkIVV6owXI8Mwr5IBN3bPoxhcPvJG2AjEmnetSIrhb8kwc4oNhlwfZwH6Jo5ARW/VEWbZtg==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-uri-escape": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/querystring-parser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.347.0.tgz", + "integrity": "sha512-5VXOhfZz78T2W7SuXf2avfjKglx1VZgZgp9Zfhrt/Rq+MTu2D+PZc5zmJHhYigD7x83jLSLogpuInQpFMA9LgA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/service-error-classification": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.347.0.tgz", + "integrity": "sha512-xZ3MqSY81Oy2gh5g0fCtooAbahqh9VhsF8vcKjVX8+XPbGC8y+kej82+MsMg4gYL8gRFB9u4hgYbNgIS6JTAvg==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/shared-ini-file-loader": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.347.0.tgz", + "integrity": "sha512-Xw+zAZQVLb+xMNHChXQ29tzzLqm3AEHsD8JJnlkeFjeMnWQtXdUfOARl5s8NzAppcKQNlVe2gPzjaKjoy2jz1Q==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.347.0.tgz", + "integrity": "sha512-58Uq1do+VsTHYkP11dTK+DF53fguoNNJL9rHRWhzP+OcYv3/mBMLoS2WPz/x9FO5mBg4ESFsug0I6mXbd36tjw==", + "dependencies": { + "@aws-sdk/eventstream-codec": "3.347.0", + "@aws-sdk/is-array-buffer": "3.310.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "@aws-sdk/util-middleware": "3.347.0", + "@aws-sdk/util-uri-escape": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.347.0.tgz", + "integrity": "sha512-838h7pbRCVYWlTl8W+r5+Z5ld7uoBObgAn7/RB1MQ4JjlkfLdN7emiITG6ueVL+7gWZNZc/4dXR/FJSzCgrkxQ==", + "dependencies": { + "@aws-sdk/protocol-http": "3.347.0", + "@aws-sdk/signature-v4": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/signature-v4-crt": "^3.118.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/signature-v4-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/smithy-client": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.347.0.tgz", + "integrity": "sha512-PaGTDsJLGK0sTjA6YdYQzILRlPRN3uVFyqeBUkfltXssvUzkm8z2t1lz2H4VyJLAhwnG5ZuZTNEV/2mcWrU7JQ==", + "dependencies": { + "@aws-sdk/middleware-stack": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.348.0.tgz", + "integrity": "sha512-nTjoJkUsJUrJTZuqaeMD9PW2//Rdg2HgfDjiyC4jmAXtayWYCi11mqauurMaUHJ3p5qJ8f5xzxm6vBTbrftPag==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.348.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/shared-ini-file-loader": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.347.0.tgz", + "integrity": "sha512-GkCMy79mdjU9OTIe5KT58fI/6uqdf8UmMdWqVHmFJ+UpEzOci7L/uw4sOXWo7xpPzLs6cJ7s5ouGZW4GRPmHFA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/url-parser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.347.0.tgz", + "integrity": "sha512-lhrnVjxdV7hl+yCnJfDZOaVLSqKjxN20MIOiijRiqaWGLGEAiSqBreMhL89X1WKCifxAs4zZf9YB9SbdziRpAA==", + "dependencies": { + "@aws-sdk/querystring-parser": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.310.0.tgz", + "integrity": "sha512-jL8509owp/xB9+Or0pvn3Fe+b94qfklc2yPowZZIFAkFcCSIdkIglz18cPDWnYAcy9JGewpMS1COXKIUhZkJsA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-base64": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64/-/util-base64-3.310.0.tgz", + "integrity": "sha512-v3+HBKQvqgdzcbL+pFswlx5HQsd9L6ZTlyPVL2LS9nNXnCcR3XgGz9jRskikRUuUvUXtkSG1J88GAOnJ/apTPg==", + "dependencies": { + "@aws-sdk/util-buffer-from": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-body-length-browser": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.310.0.tgz", + "integrity": "sha512-sxsC3lPBGfpHtNTUoGXMQXLwjmR0zVpx0rSvzTPAuoVILVsp5AU/w5FphNPxD5OVIjNbZv9KsKTuvNTiZjDp9g==", + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/util-body-length-node": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.310.0.tgz", + "integrity": "sha512-2tqGXdyKhyA6w4zz7UPoS8Ip+7sayOg9BwHNidiGm2ikbDxm1YrCfYXvCBdwaJxa4hJfRVz+aL9e+d3GqPI9pQ==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-buffer-from": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.310.0.tgz", + "integrity": "sha512-i6LVeXFtGih5Zs8enLrt+ExXY92QV25jtEnTKHsmlFqFAuL3VBeod6boeMXkN2p9lbSVVQ1sAOOYZOHYbYkntw==", + "dependencies": { + "@aws-sdk/is-array-buffer": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-config-provider": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.310.0.tgz", + "integrity": "sha512-xIBaYo8dwiojCw8vnUcIL4Z5tyfb1v3yjqyJKJWV/dqKUFOOS0U591plmXbM+M/QkXyML3ypon1f8+BoaDExrg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-defaults-mode-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.347.0.tgz", + "integrity": "sha512-+JHFA4reWnW/nMWwrLKqL2Lm/biw/Dzi/Ix54DAkRZ08C462jMKVnUlzAI+TfxQE3YLm99EIa0G7jiEA+p81Qw==", + "dependencies": { + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@aws-sdk/util-defaults-mode-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.347.0.tgz", + "integrity": "sha512-A8BzIVhAAZE5WEukoAN2kYebzTc99ZgncbwOmgCCbvdaYlk5tzguR/s+uoT4G0JgQGol/4hAMuJEl7elNgU6RQ==", + "dependencies": { + "@aws-sdk/config-resolver": "3.347.0", + "@aws-sdk/credential-provider-imds": "3.347.0", + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/property-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.347.0.tgz", + "integrity": "sha512-/WUkirizeNAqwVj0zkcrqdQ9pUm1HY5kU+qy7xTR0OebkuJauglkmSTMD+56L1JPunWqHhlwCMVRaz5eaJdSEQ==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-hex-encoding": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz", + "integrity": "sha512-sVN7mcCCDSJ67pI1ZMtk84SKGqyix6/0A1Ab163YKn+lFBQRMKexleZzpYzNGxYzmQS6VanP/cfU7NiLQOaSfA==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.310.0.tgz", + "integrity": "sha512-qo2t/vBTnoXpjKxlsC2e1gBrRm80M3bId27r0BRB2VniSSe7bL1mmzM+/HFtujm0iAxtPM+aLEflLJlJeDPg0w==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-middleware": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.347.0.tgz", + "integrity": "sha512-8owqUA3ePufeYTUvlzdJ7Z0miLorTwx+rNol5lourGQZ9JXsVMo23+yGA7nOlFuXSGkoKpMOtn6S0BT2bcfeiw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-retry": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.347.0.tgz", + "integrity": "sha512-NxnQA0/FHFxriQAeEgBonA43Q9/VPFQa8cfJDuT2A1YZruMasgjcltoZszi1dvoIRWSZsFTW42eY2gdOd0nffQ==", + "dependencies": { + "@aws-sdk/service-error-classification": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/util-stream-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-stream-browser/-/util-stream-browser-3.347.0.tgz", + "integrity": "sha512-pIbmzIJfyX26qG622uIESOmJSMGuBkhmNU7I98bzhYCet5ctC0ow9L5FZw9ljOE46P/HkEcsOhh+qTHyCXlCEQ==", + "dependencies": { + "@aws-sdk/fetch-http-handler": "3.347.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-base64": "3.310.0", + "@aws-sdk/util-hex-encoding": "3.310.0", + "@aws-sdk/util-utf8": "3.310.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/util-stream-node": { + "version": "3.348.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-stream-node/-/util-stream-node-3.348.0.tgz", + "integrity": "sha512-MFXyMUWA2oD0smBZf+sdnuyxLw8nCqyMEgYbos+6grvF1Szxn5+zbYTZrEBYiICqD1xJRLbWTzFLJU7oYm6pUg==", + "dependencies": { + "@aws-sdk/node-http-handler": "3.348.0", + "@aws-sdk/types": "3.347.0", + "@aws-sdk/util-buffer-from": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-uri-escape": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.310.0.tgz", + "integrity": "sha512-drzt+aB2qo2LgtDoiy/3sVG8w63cgLkqFIa2NFlGpUgHFWTXkqtbgf4L5QdjRGKWhmZsnqkbtL7vkSWEcYDJ4Q==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.347.0.tgz", + "integrity": "sha512-ydxtsKVtQefgbk1Dku1q7pMkjDYThauG9/8mQkZUAVik55OUZw71Zzr3XO8J8RKvQG8lmhPXuAQ0FKAyycc0RA==", + "dependencies": { + "@aws-sdk/types": "3.347.0", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.347.0.tgz", + "integrity": "sha512-6X0b9qGsbD1s80PmbaB6v1/ZtLfSx6fjRX8caM7NN0y/ObuLoX8LhYnW6WlB2f1+xb4EjaCNgpP/zCf98MXosw==", + "dependencies": { + "@aws-sdk/node-config-provider": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/util-utf8": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8/-/util-utf8-3.310.0.tgz", + "integrity": "sha512-DnLfFT8uCO22uOJc0pt0DsSNau1GTisngBCDw8jQuWT5CqogMJu4b/uXmwEqfj8B3GX6Xsz8zOd6JpRlPftQoA==", + "dependencies": { + "@aws-sdk/util-buffer-from": "3.310.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "dependencies": { + "tslib": "^2.3.1" + } + }, + "node_modules/@aws-sdk/util-waiter": { + "version": "3.347.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-waiter/-/util-waiter-3.347.0.tgz", + "integrity": "sha512-3ze/0PkwkzUzLncukx93tZgGL0JX9NaP8DxTi6WzflnL/TEul5Z63PCruRNK0om17iZYAWKrf8q2mFoHYb4grA==", + "dependencies": { + "@aws-sdk/abort-controller": "3.347.0", + "@aws-sdk/types": "3.347.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.310.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.310.0.tgz", + "integrity": "sha512-TqELu4mOuSIKQCqj63fGVs86Yh+vBx5nHRpWKNUNhB2nPTpfbziTs5c1X358be3peVWA4wPxW7Nt53KIg1tnNw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", + "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", + "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "dev": true, + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.5.2", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", + "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", + "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "dev": true, + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true, + "peer": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "peer": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "peer": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.0.1.tgz", + "integrity": "sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==", + "dependencies": { + "@smithy/types": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.0.0.tgz", + "integrity": "sha512-kc1m5wPBHQCTixwuaOh9vnak/iJm21DrSf9UK6yDE5S3mQQ4u11pqAUiKWnlrZnYkeLfAI9UEHj9OaMT1v5Umg==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@zotero/eslint-config": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@zotero/eslint-config/-/eslint-config-1.0.7.tgz", + "integrity": "sha512-g29IksYEUk8xJ4Se6dG5KXGGocYPawkNTSNh/ysDBM99YA1Xic+E9YhTDniVwhrfPrVDTC81UvI4cVnm9luZYg==", + "dev": true, + "peerDependencies": { + "eslint": ">= 3" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" + }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", + "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^4.1.2", + "get-func-name": "^2.0.0", + "loupe": "^2.3.1", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/config": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/config/-/config-3.3.9.tgz", + "integrity": "sha512-G17nfe+cY7kR0wVpc49NCYvNtelm/pPy8czHoFkAgtV1lkmcp7DHtWCdDu+C9Z7gb2WVqa9Tm3uF9aKaPbCfhg==", + "dependencies": { + "json5": "^2.2.3" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "peer": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", + "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "dev": true, + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.3", + "@eslint/js": "8.40.0", + "@humanwhocodes/config-array": "^0.11.8", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.1", + "espree": "^9.5.2", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", + "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", + "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "dev": true, + "peer": true, + "dependencies": { + "acorn": "^8.8.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "peer": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "peer": true + }, + "node_modules/fast-xml-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.4.tgz", + "integrity": "sha512-fbfMDvgBNIdDJLdLOwacjFAPYt67tr31H9ZhWSm45CDAxvd0I6WTlSOUo7K2P/K5sA5JgMKG64PI3DMcaFdWpQ==", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "peer": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "peer": true, + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true, + "peer": true + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true, + "peer": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "peer": true + }, + "node_modules/js-sdsl": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", + "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", + "dev": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.0.0.tgz", + "integrity": "sha512-p5ZTEb5h+O+iU02t0GfEjAnkdYPrQSkfuTSMkMYyIoMvUNEHsbG0bHHbfXIcfTqD2UfvjQX7mmgiFsyRwGscVw==", + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "peer": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", + "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "dependencies": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "peer": true + }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.4.tgz", + "integrity": "sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "peer": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true, + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/tslib": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", + "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/wgxpath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/wgxpath/-/wgxpath-1.2.0.tgz", + "integrity": "sha512-C1Whl8ylgNBOBA4Tg0APpjDxEDXzDtiPvEKC4l0HjHTFwYn+/WhisBGWwQXg4bwiNZ7N5xNXlXpm13NcMm/ykA==" + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/remote_js/package.json b/tests/remote_js/package.json new file mode 100644 index 00000000..9522f3db --- /dev/null +++ b/tests/remote_js/package.json @@ -0,0 +1,19 @@ +{ + "dependencies": { + "@aws-sdk/client-s3": "^3.338.0", + "@aws-sdk/client-sqs": "^3.348.0", + "config": "^3.3.9", + "jsdom": "^22.0.0", + "jszip": "^3.10.1", + "node-fetch": "^2.6.7", + "wgxpath": "^1.2.0" + }, + "devDependencies": { + "@zotero/eslint-config": "^1.0.7", + "chai": "^4.3.7", + "mocha": "^10.2.0" + }, + "scripts": { + "test": "mocha \"test/**/*.*js\"" + } +} diff --git a/tests/remote_js/test/1/collectionTest.js b/tests/remote_js/test/1/collectionTest.js new file mode 100644 index 00000000..ead30e46 --- /dev/null +++ b/tests/remote_js/test/1/collectionTest.js @@ -0,0 +1,119 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API1Before, API1After } = require("../shared.js"); + +describe('CollectionTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API1Before(); + }); + + after(async function () { + await API1After(); + }); + + const testNewSingleCollection = async () => { + const collectionName = "Test Collection"; + const json = { name: "Test Collection", parent: false }; + + const response = await API.userPost( + config.userID, + `collections?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + + const xml = API.getXMLFromResponse(response); + Helpers.assertStatusCode(response, 200); + const totalResults = Helpers.xpathEval(xml, '//feed/zapi:totalResults'); + const numCollections = Helpers.xpathEval(xml, '//feed//entry/zapi:numCollections'); + assert.equal(parseInt(totalResults), 1); + assert.equal(parseInt(numCollections), 0); + const data = API.parseDataFromAtomEntry(xml); + const jsonResponse = JSON.parse(data.content); + assert.equal(jsonResponse.name, collectionName); + return jsonResponse; + }; + + it('testNewSingleSubcollection', async function () { + let parent = await testNewSingleCollection(); + parent = parent.collectionKey; + const name = "Test Subcollection"; + const json = { name: name, parent: parent }; + + let response = await API.userPost( + config.userID, + `collections?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + let xml = API.getXMLFromResponse(response); + assert.equal(parseInt(Helpers.xpathEval(xml, '//feed/zapi:totalResults')), 1); + + const dataSub = API.parseDataFromAtomEntry(xml); + + const jsonResponse = JSON.parse(dataSub.content); + assert.equal(jsonResponse.name, name); + assert.equal(jsonResponse.parent, parent); + response = await API.userGet( + config.userID, + `collections/${parent}?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 200); + xml = API.getXMLFromResponse(response); + assert.equal(parseInt(Helpers.xpathEval(xml, '/atom:entry/zapi:numCollections')), 1); + }); + + it('testNewSingleCollectionWithoutParentProperty', async function () { + const name = "Test Collection"; + const json = { name: name }; + + const response = await API.userPost( + config.userID, + `collections?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + const xml = API.getXMLFromResponse(response); + assert.equal(parseInt(Helpers.xpathEval(xml, '//feed/zapi:totalResults')), 1); + const data = API.parseDataFromAtomEntry(xml); + const jsonResponse = JSON.parse(data.content); + assert.equal(jsonResponse.name, name); + }); + + it('testEditSingleCollection', async function () { + API.useAPIVersion(2); + const xml = await API.createCollection("Test", false); + const data = API.parseDataFromAtomEntry(xml); + const key = data.key; + API.useAPIVersion(1); + + const xmlCollection = await API.getCollectionXML(data.key); + const contentElement = Helpers.xpathEval(xmlCollection, '//atom:entry/atom:content', true); + const etag = contentElement.getAttribute("etag"); + assert.isString(etag); + const newName = "Test 2"; + const json = { name: newName, parent: false }; + + const response = await API.userPut( + config.userID, + `collections/${key}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Match": etag + } + ); + Helpers.assertStatusCode(response, 200); + const xmlResponse = API.getXMLFromResponse(response); + const dataResponse = API.parseDataFromAtomEntry(xmlResponse); + const jsonResponse = JSON.parse(dataResponse.content); + assert.equal(jsonResponse.name, newName); + }); +}); diff --git a/tests/remote_js/test/1/itemsTest.js b/tests/remote_js/test/1/itemsTest.js new file mode 100644 index 00000000..9a8d7b7e --- /dev/null +++ b/tests/remote_js/test/1/itemsTest.js @@ -0,0 +1,36 @@ +const { assert } = require('chai'); +const API = require('../../api2.js'); +var config = require('config'); +const Helpers = require('../../helpers2.js'); +const { API1Before, API1After } = require("../shared.js"); + +describe('ItemTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API1Before(); + await API.setKeyOption(config.userID, config.apiKey, 'libraryNotes', 1); + }); + + after(async function () { + await API1After(); + }); + + it('testCreateItemWithChildren', async function () { + let json = await API.getItemTemplate("newspaperArticle"); + let noteJSON = await API.getItemTemplate("note"); + noteJSON.note = "

Here's a test note

"; + json.notes = [noteJSON]; + let response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ items: [json] }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 201); + let xml = API.getXMLFromResponse(response); + Helpers.assertNumResults(response, 1); + const numChildren = Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'); + assert.equal(parseInt(numChildren), 1); + }); +}); diff --git a/tests/remote_js/test/2/atomTest.js b/tests/remote_js/test/2/atomTest.js new file mode 100644 index 00000000..ce3b3157 --- /dev/null +++ b/tests/remote_js/test/2/atomTest.js @@ -0,0 +1,119 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('CollectionTests', function () { + this.timeout(config.timeout); + let keyObj = {}; + before(async function () { + await API2Before(); + const item1 = { + title: 'Title', + creators: [ + { + creatorType: 'author', + firstName: 'First', + lastName: 'Last', + }, + ], + }; + + const key1 = await API.createItem('book', item1, null, 'key'); + const itemXml1 + = '' + + '' + + '
' + + '
Last, First. Title, n.d.
' + + '
' + + '{"itemKey":"","itemVersion":0,"itemType":"book","title":"Title","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{}}' + + '
'; + keyObj[key1] = itemXml1; + + const item2 = { + title: 'Title 2', + creators: [ + { + creatorType: 'author', + firstName: 'First', + lastName: 'Last', + }, + { + creatorType: 'editor', + firstName: 'Ed', + lastName: 'McEditor', + }, + ], + }; + + const key2 = await API.createItem('book', item2, null, 'key'); + const itemXml2 + = '' + + '' + + '
' + + '
Last, First. Title 2. Edited by Ed McEditor, n.d.
' + + '
' + + '{"itemKey":"","itemVersion":0,"itemType":"book","title":"Title 2","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"},{"creatorType":"editor","firstName":"Ed","lastName":"McEditor"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{}}' + + '
'; + keyObj[key2] = itemXml2; + }); + + after(async function () { + await API2After(); + }); + + it('testFeedURIs', async function () { + const userID = config.userID; + + const response = await API.userGet( + userID, + "items?key=" + config.apiKey + ); + Helpers.assertStatusCode(response, 200); + const xml = API.getXMLFromResponse(response); + const links = Helpers.xpathEval(xml, '/atom:feed/atom:link', true, true); + assert.equal(config.apiURLPrefix + "users/" + userID + "/items", links[0].getAttribute('href')); + + // 'order'/'sort' should stay as-is, not turn into 'sort'/'direction' + const response2 = await API.userGet( + userID, + "items?key=" + config.apiKey + "&order=dateModified&sort=asc" + ); + Helpers.assertStatusCode(response2, 200); + const xml2 = API.getXMLFromResponse(response2); + const links2 = Helpers.xpathEval(xml2, '/atom:feed/atom:link', true, true); + assert.equal(config.apiURLPrefix + "users/" + userID + "/items?order=dateModified&sort=asc", links2[0].getAttribute('href')); + }); + + + it('testMultiContent', async function () { + const keys = Object.keys(keyObj); + const keyStr = keys.join(','); + + const response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&itemKey=${keyStr}&content=bib,json`, + ); + Helpers.assertStatusCode(response, 200); + const xml = API.getXMLFromResponse(response); + assert.equal(Helpers.xpathEval(xml, '/atom:feed/zapi:totalResults'), keys.length); + + const entries = Helpers.xpathEval(xml, '//atom:entry', true, true); + for (const entry of entries) { + const key = entry.getElementsByTagName("zapi:key")[0].innerHTML; + let content = entry.getElementsByTagName("content")[0].outerHTML; + + content = content.replace( + 'Last, Title.', + apa: '(Last, n.d.)' + }, + bib: { + default: '
Last, First. Title, n.d.
', + apa: '
Last, F. (n.d.). Title.
' + } + + }; + + key = await API.createItem("book", { + title: "Title 2", + creators: [ + { + creatorType: "author", + firstName: "First", + lastName: "Last" + } + ] + }, null, 'key'); + + + items[key] = { + + citation: { + default: 'Last, Title 2.', + apa: '(Last, n.d.)' + }, + bib: { + default: '
Last, First. Title 2. Edited by Ed McEditor, n.d.
', + apa: '
Last, F. (n.d.). Title 2 (E. McEditor, Ed.).
' + } + + }; + }); + + after(async function () { + await API2After(); + }); + + it('testContentCitationMulti', async function () { + let keys = Object.keys(items); + let keyStr = keys.join(','); + for (let style of styles) { + let response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&itemKey=${keyStr}&content=citation${style == "default" ? "" : "&style=" + encodeURIComponent(style)}` + ); + Helpers.assert200(response); + Helpers.assertTotalResults(response, keys.length); + let xml = API.getXMLFromResponse(response); + assert.equal(Helpers.xpathEval(xml, '/atom:feed/zapi:totalResults'), keys.length); + + let entries = Helpers.xpathEval(xml, '//atom:entry', true, true); + for (let entry of entries) { + const key = entry.getElementsByTagName("zapi:key")[0].innerHTML; + let content = entry.getElementsByTagName("content")[0].outerHTML; + // Add zapi namespace + content = content.replace(' { + const name = "Test Collection"; + + const xml = await API.createCollection(name, false, true, 'atom'); + assert.equal(parseInt(Helpers.xpathEval(xml, '/atom:feed/zapi:totalResults')), 1); + + const data = API.parseDataFromAtomEntry(xml); + + const json = JSON.parse(data.content); + assert.equal(name, json.name); + return data; + }; + + it('testNewSubcollection', async function () { + const data = await testNewCollection(); + + const subName = "Test Subcollection"; + const parent = data.key; + + const subXml = await API.createCollection(subName, parent, true, 'atom'); + assert.equal(parseInt(Helpers.xpathEval(subXml, '/atom:feed/zapi:totalResults')), 1); + + const subData = API.parseDataFromAtomEntry(subXml); + const subJson = JSON.parse(subData.content); + assert.equal(subName, subJson.name); + assert.equal(parent, subJson.parentCollection); + + const response = await API.userGet( + config.userID, + `collections/${parent}?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 200); + const xmlRes = API.getXMLFromResponse(response); + assert.equal(parseInt(Helpers.xpathEval(xmlRes, '/atom:entry/zapi:numCollections')), 1); + }); + + it('testNewMultipleCollections', async function () { + const xml = await API.createCollection('Test Collection 1', false, true); + const data = API.parseDataFromAtomEntry(xml); + + const name1 = 'Test Collection 2'; + const name2 = 'Test Subcollection'; + const parent2 = data.key; + + const json = { + collections: [ + { + name: name1, + }, + { + name: name2, + parentCollection: parent2, + }, + ], + }; + + const response = await API.userPost( + config.userID, + `collections?key=${config.apiKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + + Helpers.assertStatusCode(response, 200); + const jsonResponse = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(jsonResponse.success), 2); + const xmlResponse = await API.getCollectionXML(Object.keys(jsonResponse.success).map(key => jsonResponse.success[key])); + assert.equal(parseInt(Helpers.xpathEval(xmlResponse, '/atom:feed/zapi:totalResults')), 2); + + const contents = Helpers.xpathEval(xmlResponse, '/atom:feed/atom:entry/atom:content', false, true); + let content = JSON.parse(contents.shift()); + assert.equal(name1, content.name); + assert.notOk(content.parentCollection); + content = JSON.parse(contents.shift()); + assert.equal(name2, content.name); + assert.equal(parent2, content.parentCollection); + }); + + it('testEditMultipleCollections', async function () { + let xml = await API.createCollection("Test 1", false, true, 'atom'); + let data = API.parseDataFromAtomEntry(xml); + let key1 = data.key; + xml = await API.createCollection("Test 2", false, true, 'atom'); + data = API.parseDataFromAtomEntry(xml); + let key2 = data.key; + + let newName1 = "Test 1 Modified"; + let newName2 = "Test 2 Modified"; + let response = await API.userPost( + config.userID, + "collections?key=" + config.apiKey, + JSON.stringify({ + collections: [ + { + collectionKey: key1, + name: newName1 + }, + { + collectionKey: key2, + name: newName2 + } + ] + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": data.version + } + ); + Helpers.assertStatusCode(response, 200); + let json = API.getJSONFromResponse(response); + + assert.lengthOf(Object.keys(json.success), 2); + xml = await API.getCollectionXML(Object.keys(json.success).map(key => json.success[key])); + assert.equal(parseInt(Helpers.xpathEval(xml, '/atom:feed/zapi:totalResults')), 2); + + let contents = Helpers.xpathEval(xml, '/atom:feed/atom:entry/atom:content', false, true); + let content = JSON.parse(contents[0]); + assert.equal(content.name, newName1); + assert.notOk(content.parentCollection); + content = JSON.parse(contents[1]); + assert.equal(content.name, newName2); + assert.notOk(content.parentCollection); + }); + + it('testCollectionItemChange', async function () { + const collectionKey1 = await API.createCollection('Test', false, true, 'key'); + const collectionKey2 = await API.createCollection('Test', false, true, 'key'); + + let xml = await API.createItem('book', { + collections: [collectionKey1], + }, true, 'atom'); + let data = API.parseDataFromAtomEntry(xml); + const itemKey1 = data.key; + const itemVersion1 = data.version; + let json = JSON.parse(data.content); + assert.equal(json.collections[0], collectionKey1); + + xml = await API.createItem('journalArticle', { + collections: [collectionKey2], + }, true, 'atom'); + data = API.parseDataFromAtomEntry(xml); + const itemKey2 = data.key; + const itemVersion2 = data.version; + json = JSON.parse(data.content); + assert.equal(json.collections[0], collectionKey2); + + xml = await API.getCollectionXML(collectionKey1); + + assert.equal(parseInt(Helpers.xpathEval(xml, '//atom:entry/zapi:numItems')), 1); + + xml = await API.getCollectionXML(collectionKey2); + let collectionData2 = API.parseDataFromAtomEntry(xml); + assert.equal(parseInt(Helpers.xpathEval(xml, '//atom:entry/zapi:numItems')), 1); + + var libraryVersion = await API.getLibraryVersion(); + + // Add items to collection + var response = await API.userPatch( + config.userID, + `items/${itemKey1}?key=${config.apiKey}`, + JSON.stringify({ + collections: [collectionKey1, collectionKey2], + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion1, + } + ); + Helpers.assertStatusCode(response, 204); + + // Item version should change + xml = await API.getItemXML(itemKey1); + data = API.parseDataFromAtomEntry(xml); + assert.equal(parseInt(data.version), parseInt(libraryVersion) + 1); + + // Collection timestamp shouldn't change, but numItems should + xml = await API.getCollectionXML(collectionKey2); + data = API.parseDataFromAtomEntry(xml); + assert.equal(parseInt(Helpers.xpathEval(xml, '//atom:entry/zapi:numItems')), 2); + assert.equal(data.version, collectionData2.version); + collectionData2 = data; + + libraryVersion = await API.getLibraryVersion(); + + // Remove collections + response = await API.userPatch( + config.userID, + `items/${itemKey2}?key=${config.apiKey}`, + JSON.stringify({ collections: [] }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion2, + } + ); + Helpers.assertStatusCode(response, 204); + + // Item version should change + xml = await API.getItemXML(itemKey2); + data = API.parseDataFromAtomEntry(xml); + assert.equal(parseInt(data.version), parseInt(libraryVersion) + 1); + + // Collection timestamp shouldn't change, but numItems should + xml = await API.getCollectionXML(collectionKey2); + data = API.parseDataFromAtomEntry(xml); + assert.equal(parseInt(Helpers.xpathEval(xml, '//atom:entry/zapi:numItems')), 1); + assert.equal(data.version, collectionData2.version); + + // Check collections arrays and numItems + xml = await API.getItemXML(itemKey1); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + assert.lengthOf(json.collections, 2); + assert.include(json.collections, collectionKey1); + assert.include(json.collections, collectionKey2); + + xml = await API.getItemXML(itemKey2); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + assert.lengthOf(json.collections, 0); + + xml = await API.getCollectionXML(collectionKey1); + assert.equal(parseInt(Helpers.xpathEval(xml, '//atom:entry/zapi:numItems')), 1); + + xml = await API.getCollectionXML(collectionKey2); + assert.equal(parseInt(Helpers.xpathEval(xml, '//atom:entry/zapi:numItems')), 1); + }); + + it('testCollectionChildItemError', async function () { + const collectionKey = await API.createCollection('Test', false, this, 'key'); + + const key = await API.createItem('book', {}, true, 'key'); + const xml = await API.createNoteItem('

Test Note

', key, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + json.collections = [collectionKey]; + json.relations = {}; + + const response = await API.userPut( + config.userID, + `items/${data.key}?key=${config.apiKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 400, 'Child items cannot be assigned to collections'); + }); + + it('testCollectionItems', async function () { + const collectionKey = await API.createCollection('Test', false, true, 'key'); + + let xml = await API.createItem("book", { collections: [collectionKey] }, this); + let data = API.parseDataFromAtomEntry(xml); + let itemKey1 = data.key; + let json = JSON.parse(data.content); + assert.deepEqual([collectionKey], json.collections); + + xml = await API.createItem("journalArticle", { collections: [collectionKey] }, true); + data = API.parseDataFromAtomEntry(xml); + let itemKey2 = data.key; + json = JSON.parse(data.content); + assert.deepEqual([collectionKey], json.collections); + + let childItemKey1 = await API.createAttachmentItem("linked_url", [], itemKey1, true, 'key'); + let childItemKey2 = await API.createAttachmentItem("linked_url", [], itemKey2, true, 'key'); + + const response1 = await API.userGet( + config.userID, + `collections/${collectionKey}/items?key=${config.apiKey}&format=keys` + ); + Helpers.assertStatusCode(response1, 200); + let keys = response1.data.split("\n").map(key => key.trim()).filter(key => key.length != 0); + assert.lengthOf(keys, 4); + assert.include(keys, itemKey1); + assert.include(keys, itemKey2); + assert.include(keys, childItemKey1); + assert.include(keys, childItemKey2); + + const response2 = await API.userGet( + config.userID, + `collections/${collectionKey}/items/top?key=${config.apiKey}&format=keys` + ); + Helpers.assertStatusCode(response2, 200); + keys = response2.data.split("\n").map(key => key.trim()).filter(key => key.length != 0); + assert.lengthOf(keys, 2); + assert.include(keys, itemKey1); + assert.include(keys, itemKey2); + }); +}); diff --git a/tests/remote_js/test/2/creatorTest.js b/tests/remote_js/test/2/creatorTest.js new file mode 100644 index 00000000..fa7a1642 --- /dev/null +++ b/tests/remote_js/test/2/creatorTest.js @@ -0,0 +1,66 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('CreatorTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + it('testCreatorSummary', async function () { + const xml = await API.createItem('book', + { + creators: [ + { + creatorType: 'author', + name: 'Test' + } + ] + }, true); + + const data = API.parseDataFromAtomEntry(xml); + const itemKey = data.key; + const json = JSON.parse(data.content); + + const creatorSummary = Helpers.xpathEval(xml, '//atom:entry/zapi:creatorSummary'); + assert.equal('Test', creatorSummary); + + json.creators.push({ + creatorType: 'author', + firstName: 'Alice', + lastName: 'Foo' + }); + + const response = await API.userPut(config.userID, `items/${itemKey}?key=${config.apiKey}`, JSON.stringify(json)); + Helpers.assertStatusCode(response, 204); + + const updatedXml = await API.getItemXML(itemKey); + const updatedCreatorSummary = Helpers.xpathEval(updatedXml, '//atom:entry/zapi:creatorSummary'); + assert.equal('Test and Foo', updatedCreatorSummary); + + const updatedData = API.parseDataFromAtomEntry(updatedXml); + const updatedJson = JSON.parse(updatedData.content); + + updatedJson.creators.push({ + creatorType: 'author', + firstName: 'Bob', + lastName: 'Bar' + }); + + const response2 = await API.userPut(config.userID, `items/${itemKey}?key=${config.apiKey}`, JSON.stringify(updatedJson)); + Helpers.assertStatusCode(response2, 204); + + const finalXml = await API.getItemXML(itemKey); + const finalCreatorSummary = Helpers.xpathEval(finalXml, '//atom:entry/zapi:creatorSummary'); + assert.equal('Test et al.', finalCreatorSummary); + }); +}); diff --git a/tests/remote_js/test/2/fileTest.js b/tests/remote_js/test/2/fileTest.js new file mode 100644 index 00000000..4d0a08c4 --- /dev/null +++ b/tests/remote_js/test/2/fileTest.js @@ -0,0 +1,732 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); +const { S3Client, DeleteObjectsCommand, PutObjectCommand } = require("@aws-sdk/client-s3"); +const fs = require('fs'); +const HTTP = require('../../httpHandler.js'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +describe('FileTestTests', function () { + this.timeout(config.timeout); + let toDelete = []; + const s3Client = new S3Client({ region: "us-east-1" }); + + before(async function () { + await API2Before(); + try { + fs.mkdirSync("./work"); + } + catch {} + }); + + after(async function () { + await API2After(); + fs.rm("./work", { recursive: true, force: true }, (e) => { + if (e) console.log(e); + }); + if (toDelete.length > 0) { + const commandInput = { + Bucket: config.s3Bucket, + Delete: { + Objects: toDelete.map((x) => { + return { Key: x }; + }) + } + }; + const command = new DeleteObjectsCommand(commandInput); + await s3Client.send(command); + } + }); + + + const testNewEmptyImportedFileAttachmentItem = async () => { + let xml = await API.createAttachmentItem("imported_file", [], false, this); + let data = API.parseDataFromAtomEntry(xml); + return data; + }; + + const testGetFile = async () => { + const addFileData = await testAddFileExisting(); + + // Get in view mode + const userGetViewModeResponse = await API.userGet(config.userID, `items/${addFileData.key}/file/view?key=${config.apiKey}`); + Helpers.assert302(userGetViewModeResponse); + const location = userGetViewModeResponse.headers.location[0]; + Helpers.assertRegExp(/^https?:\/\/[^/]+\/[a-zA-Z0-9%]+\/[a-f0-9]{64}\/test_/, location); + const filenameEncoded = encodeURIComponent(addFileData.filename); + assert.equal(filenameEncoded, location.substring(location.length - filenameEncoded.length)); + + // Get from view mode + const viewModeResponse = await HTTP.get(location); + Helpers.assert200(viewModeResponse); + assert.equal(addFileData.md5, Helpers.md5(viewModeResponse.data)); + + // Get in download mode + const userGetDownloadModeResponse = await API.userGet(config.userID, `items/${addFileData.key}/file?key=${config.apiKey}`); + Helpers.assert302(userGetDownloadModeResponse); + const downloadModeLocation = userGetDownloadModeResponse.headers.location[0]; + + // Get from S3 + const s3Response = await HTTP.get(downloadModeLocation); + Helpers.assert200(s3Response); + assert.equal(addFileData.md5, Helpers.md5(s3Response.data)); + return { + key: addFileData.key, + response: s3Response + }; + }; + + it('testAddFileLinkedAttachment', async function () { + let xml = await API.createAttachmentItem("linked_file", [], false, this); + let data = API.parseDataFromAtomEntry(xml); + + let file = "./work/file"; + let fileContents = getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let mtime = fs.statSync(file).mtimeMs; + let size = fs.statSync(file).size; + let contentType = "text/plain"; + let charset = "utf-8"; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: contentType, + charset: charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert400(response); + }); + + // Errors + it('testAddFileFullParams', async function () { + let xml = await API.createAttachmentItem("imported_file", [], false, this); + + let data = API.parseDataFromAtomEntry(xml); + let serverDateModified = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + await new Promise(r => setTimeout(r, 2000)); + let originalVersion = data.version; + let file = "./work/file"; + let fileContents = getRandomUnicodeString(); + await fs.promises.writeFile(file, fileContents); + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let mtime = parseInt((await fs.promises.stat(file)).mtimeMs); + let size = parseInt((await fs.promises.stat(file)).size); + let contentType = "text/plain"; + let charset = "utf-8"; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: contentType, + charset: charset, + params: 1 + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + let json = JSON.parse(response.data); + assert.ok(json); + toDelete.push(hash); + + // Generate form-data -- taken from S3::getUploadPostData() + let boundary = "---------------------------" + Helpers.md5(Helpers.uniqueID()); + let prefix = ""; + for (let key in json.params) { + prefix += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"\r\n\r\n" + json.params[key] + "\r\n"; + } + prefix += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n"; + let suffix = "\r\n--" + boundary + "--"; + + // Upload to S3 + response = await HTTP.post( + json.url, + prefix + fileContents + suffix, + { + "Content-Type": "multipart/form-data; boundary=" + boundary + } + ); + Helpers.assert201(response); + + // Register upload + response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${data.key}?key=${config.apiKey}&content=json` + ); + xml = API.getXMLFromResponse(response); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + assert.equal(hash, json.md5); + assert.equal(filename, json.filename); + assert.equal(mtime, json.mtime); + assert.equal(contentType, json.contentType); + assert.equal(charset, json.charset); + const updated = Helpers.xpathEval(xml, '/atom:entry/atom:updated'); + + // Make sure version has changed + assert.notEqual(originalVersion, data.version); + }); + + const getRandomUnicodeString = function () { + return "Âéìøü 这是一个测试。 " + Helpers.uniqueID(); + }; + + it('testExistingFileWithOldStyleFilename', async function () { + let fileContents = getRandomUnicodeString(); + let hash = Helpers.md5(fileContents); + let filename = 'test.txt'; + let size = fileContents.length; + + let parentKey = await API.createItem("book", false, this, 'key'); + let xml = await API.createAttachmentItem("imported_file", [], parentKey, this); + let data = API.parseDataFromAtomEntry(xml); + let key = data.key; + let mtime = Date.now(); + let contentType = 'text/plain'; + let charset = 'utf-8'; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: size, + mtime, + contentType, + charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + let json = JSON.parse(response.data); + assert.isOk(json); + + // Upload to old-style location + toDelete.push(`${hash}/${filename}`); + toDelete.push(hash); + const putCommand = new PutObjectCommand({ + Bucket: config.s3Bucket, + Key: `${hash}/${filename}`, + Body: fileContents + }); + await s3Client.send(putCommand); + + // Register upload + response = await API.userPost( + config.userID, + `items/${key}/file?key=${config.apiKey}`, + `upload=${json.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + // The file should be accessible on the item at the old-style location + response = await API.userGet( + config.userID, + `items/${key}/file?key=${config.apiKey}` + ); + Helpers.assert302(response); + let location = response.headers.location[0]; + // bucket.s3.amazonaws.com or s3.amazonaws.com/bucket + let matches = location.match(/^https:\/\/(?:[^/]+|.+config.s3Bucket)\/([a-f0-9]{32})\/test.txt\?/); + Helpers.assertEquals(2, matches.length); + Helpers.assertEquals(hash, matches[1]); + + // Get upload authorization for the same file and filename on another item, which should + // result in 'exists', even though we uploaded to the old-style location + parentKey = await API.createItem("book", false, this, 'key'); + xml = await API.createAttachmentItem("imported_file", [], parentKey, this); + data = API.parseDataFromAtomEntry(xml); + key = data.key; + response = await API.userPost( + config.userID, + `items/${key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: size, + mtime, + contentType, + charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + let postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + Helpers.assertEquals(1, postJSON.exists); + + // Get in download mode + response = await API.userGet( + config.userID, + `items/${key}/file?key=${config.apiKey}` + ); + Helpers.assert302(response); + location = response.headers.location[0]; + matches = location.match(/^https:\/\/(?:[^/]+|.+config.s3Bucket)\/([a-f0-9]{32})\/test.txt\?/); + Helpers.assertEquals(2, matches.length); + Helpers.assertEquals(hash, matches[1]); + + // Get from S3 + response = await HTTP.get(location); + Helpers.assert200(response); + Helpers.assertEquals(fileContents, response.data); + Helpers.assertEquals(`${contentType}; charset=${charset}`, response.headers['content-type'][0]); + + // Get upload authorization for the same file and different filename on another item, + // which should result in 'exists' and a copy of the file to the hash-only location + parentKey = await API.createItem("book", false, this, 'key'); + xml = await API.createAttachmentItem("imported_file", [], parentKey, this); + data = API.parseDataFromAtomEntry(xml); + key = data.key; + // Also use a different content type + contentType = 'application/x-custom'; + response = await API.userPost( + config.userID, + `items/${key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: hash, + filename: "test2.txt", + filesize: size, + mtime, + contentType + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + Helpers.assertEquals(1, postJSON.exists); + + // Get in download mode + response = await API.userGet( + config.userID, + `items/${key}/file?key=${config.apiKey}` + ); + Helpers.assert302(response); + location = response.headers.location[0]; + matches = location.match(/^https:\/\/(?:[^/]+|.+config.s3Bucket)\/([a-f0-9]{32})\?/); + Helpers.assertEquals(2, matches.length); + Helpers.assertEquals(hash, matches[1]); + + // Get from S3 + response = await HTTP.get(location); + Helpers.assert200(response); + Helpers.assertEquals(fileContents, response.data); + Helpers.assertEquals(contentType, response.headers['content-type'][0]); + }); + + const testAddFileFull = async () => { + let xml = await API.createItem("book", false, this); + let data = API.parseDataFromAtomEntry(xml); + let parentKey = data.key; + xml = await API.createAttachmentItem("imported_file", [], parentKey, this); + data = API.parseDataFromAtomEntry(xml); + let file = "./work/file"; + let fileContents = getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let mtime = fs.statSync(file).mtime * 1000; + let size = fs.statSync(file).size; + let contentType = "text/plain"; + let charset = "utf-8"; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: contentType, + charset: charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + let json = JSON.parse(response.data); + assert.isOk(json); + toDelete.push(hash); + + // Upload to S3 + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": `${json.contentType}` + } + ); + Helpers.assert201(response); + + // Register upload + + // No If-None-Match + response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + `upload=${json.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + } + ); + Helpers.assert428(response); + + // Invalid upload key + response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + `upload=invalidUploadKey`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert400(response); + + response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + `upload=${json.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${data.key}?key=${config.apiKey}&content=json` + ); + xml = API.getXMLFromResponse(response); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + assert.equal(hash, json.md5); + assert.equal(filename, json.filename); + assert.equal(mtime, json.mtime); + assert.equal(contentType, json.contentType); + assert.equal(charset, json.charset); + + return { + key: data.key, + json: json, + size: size + }; + }; + + it('testAddFileAuthorizationErrors', async function () { + const data = await testNewEmptyImportedFileAttachmentItem(); + const fileContents = getRandomUnicodeString(); + const hash = Helpers.md5(fileContents); + const mtime = Date.now(); + const size = fileContents.length; + const filename = `test_${fileContents}`; + + const fileParams = { + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: "text/plain", + charset: "utf-8" + }; + + // Check required params + const requiredParams = ["md5", "filename", "filesize", "mtime"]; + for (let exclude of requiredParams) { + const response = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams(fileParams, [exclude]), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + }); + Helpers.assert400(response); + } + + // Seconds-based mtime + const fileParams2 = { ...fileParams, mtime: Math.round(mtime / 1000) }; + await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams(fileParams2), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + }); + // TODO: Enable this test when the dataserver enforces it + //Helpers.assert400(response2); + //assert.equal('mtime must be specified in milliseconds', response2.data); + + // Invalid If-Match + const response3 = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": Helpers.md5("invalidETag") + }); + Helpers.assert412(response3); + + // Missing If-None-Match + const response4 = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded" + }); + Helpers.assert428(response4); + + // Invalid If-None-Match + const response5 = await API.userPost( + config.userID, + `items/${data.key}/file?key=${config.apiKey}`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "invalidETag" + }); + Helpers.assert400(response5); + }); + + + it('testAddFilePartial', async function () { + const getFileData = await testGetFile(); + const response = await API.userGet( + config.userID, + `items/${getFileData.key}?key=${config.apiKey}&content=json` + ); + const xml = API.getXMLFromResponse(response); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const data = API.parseDataFromAtomEntry(xml); + const originalVersion = data.version; + + const oldFilename = "./work/old"; + const fileContents = getFileData.response.data; + fs.writeFileSync(oldFilename, fileContents); + + const newFilename = "./work/new"; + const patchFilename = "./work/patch"; + + const algorithms = { + bsdiff: `bsdiff ${oldFilename} ${newFilename} ${patchFilename}`, + xdelta: `xdelta -f -e -9 -S djw -s ${oldFilename} ${newFilename} ${patchFilename}`, + vcdiff: `vcdiff encode -dictionary ${oldFilename} -target ${newFilename} -delta ${patchFilename}`, + }; + + for (let [algo, cmd] of Object.entries(algorithms)) { + // Create random contents + fs.writeFileSync(newFilename, getRandomUnicodeString() + Helpers.uniqueID()); + const newHash = Helpers.md5File(newFilename); + + // Get upload authorization + const fileParams = { + md5: newHash, + filename: `test_${fileContents}`, + filesize: fs.statSync(newFilename).size, + mtime: parseInt(fs.statSync(newFilename).mtimeMs), + contentType: "text/plain", + charset: "utf-8", + }; + + const postResponse = await API.userPost( + config.userID, + `items/${getFileData.key}/file?key=${config.apiKey}`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": Helpers.md5File(oldFilename), + } + ); + Helpers.assert200(postResponse); + let json = JSON.parse(postResponse.data); + assert.isOk(json); + try { + await exec(cmd); + } + catch { + console.log("Warning: Could not run " + algo); + continue; + } + + const patch = fs.readFileSync(patchFilename); + assert.notEqual("", patch.toString()); + + toDelete.push(newHash); + + // Upload patch file + let response = await API.userPatch( + config.userID, + `items/${getFileData.key}/file?key=${config.apiKey}&algorithm=${algo}&upload=${json.uploadKey}`, + patch, + { + "If-Match": Helpers.md5File(oldFilename), + } + ); + Helpers.assert204(response); + + fs.rm(patchFilename, (_) => {}); + fs.renameSync(newFilename, oldFilename); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${getFileData.key}?key=${config.apiKey}&content=json` + ); + const xml = API.getXMLFromResponse(response); + const data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + Helpers.assertEquals(fileParams.md5, json.md5); + Helpers.assertEquals(fileParams.mtime, json.mtime); + Helpers.assertEquals(fileParams.contentType, json.contentType); + Helpers.assertEquals(fileParams.charset, json.charset); + + // Make sure version has changed + assert.notEqual(originalVersion, data.version); + + // Verify file in S3 + const fileResponse = await API.userGet( + config.userID, + `items/${getFileData.key}/file?key=${config.apiKey}` + ); + Helpers.assert302(fileResponse); + const location = fileResponse.headers.location[0]; + + const getFileResponse = await HTTP.get(location); + Helpers.assert200(getFileResponse); + Helpers.assertEquals(fileParams.md5, Helpers.md5(getFileResponse.data)); + Helpers.assertEquals( + `${fileParams.contentType}${fileParams.contentType && fileParams.charset ? `; charset=${fileParams.charset}` : "" + }`, + getFileResponse.headers["content-type"][0] + ); + } + }); + + const testAddFileExisting = async () => { + const addFileData = await testAddFileFull(); + const key = addFileData.key; + const json = addFileData.json; + const md5 = json.md5; + const size = addFileData.size; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: json.md5, + filename: json.filename, + filesize: size, + mtime: json.mtime, + contentType: json.contentType, + charset: json.charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": json.md5 + } + ); + Helpers.assert200(response); + let postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + assert.equal(1, postJSON.exists); + + // Get upload authorization for existing file with different filename + response = await API.userPost( + config.userID, + `items/${key}/file?key=${config.apiKey}`, + Helpers.implodeParams({ + md5: json.md5, + filename: json.filename + "等", // Unicode 1.1 character, to test signature generation + filesize: size, + mtime: json.mtime, + contentType: json.contentType, + charset: json.charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": json.md5 + } + ); + Helpers.assert200(response); + postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + assert.equal(1, postJSON.exists); + + const testResult = { + key: key, + md5: md5, + filename: json.filename + "等" + }; + return testResult; + }; +}); diff --git a/tests/remote_js/test/2/fullText.mjs b/tests/remote_js/test/2/fullText.mjs new file mode 100644 index 00000000..87a8fd03 --- /dev/null +++ b/tests/remote_js/test/2/fullText.mjs @@ -0,0 +1,314 @@ +import chai from 'chai'; +const assert = chai.assert; +import config from 'config'; +import API from '../../api2.js'; +import Helpers from '../../helpers2.js'; +import shared from "../shared.js"; +import { s3 } from "../../full-text-indexer/index.mjs"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; + +describe('FullTextTests', function () { + this.timeout(config.timeout); + const s3Client = new S3Client({ region: "us-east-1" }); + + before(async function () { + await shared.API2Before(); + }); + + after(async function () { + await shared.API2After(); + }); + + it('testSetItemContent', async function () { + const key = await API.createItem("book", false, this, 'key'); + const xml = await API.createAttachmentItem("imported_url", [], key, this, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + + let response = await API.userGet( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey + ); + Helpers.assertStatusCode(response, 404); + assert.isUndefined(response.headers["last-modified-version"]); + + const libraryVersion = await API.getLibraryVersion(); + + const content = "Here is some full-text content"; + const pages = 50; + + // No Content-Type + response = await API.userPut( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey, + content + ); + Helpers.assertStatusCode(response, 400, "Content-Type must be application/json"); + + // Store content + response = await API.userPut( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey, + JSON.stringify({ + content: content, + indexedPages: pages, + totalPages: pages, + invalidParam: "shouldBeIgnored" + }), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 204); + const contentVersion = response.headers["last-modified-version"][0]; + assert.isAbove(parseInt(contentVersion), parseInt(libraryVersion)); + + // Retrieve it + response = await API.userGet( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.headers['content-type'][0], "application/json"); + const json = JSON.parse(response.data); + assert.equal(content, json.content); + assert.include(Object.keys(json), 'indexedPages'); + assert.include(Object.keys(json), 'totalPages'); + assert.equal(pages, json.indexedPages); + assert.equal(pages, json.totalPages); + assert.notInclude(Object.keys(json), "indexedChars"); + assert.notInclude(Object.keys(json), "invalidParam"); + assert.equal(contentVersion, response.headers['last-modified-version'][0]); + }); + + it('testModifyAttachmentWithFulltext', async function () { + const key = await API.createItem("book", false, true, 'key'); + const xml = await API.createAttachmentItem("imported_url", [], key, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const content = "Here is some full-text content"; + const pages = 50; + + // Store content + const response = await API.userPut( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey, + JSON.stringify({ + content: content, + indexedPages: pages, + totalPages: pages + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 204); + + const json = JSON.parse(data.content); + json.title = "This is a new attachment title"; + json.contentType = 'text/plain'; + + // Modify attachment item + const response2 = await API.userPut( + config.userID, + "items/" + data.key + "?key=" + config.apiKey, + JSON.stringify(json), + { "If-Unmodified-Since-Version": data.version } + ); + Helpers.assertStatusCode(response2, 204); + }); + + it('testNewerContent', async function () { + await API.userClear(config.userID); + // Store content for one item + let key = await API.createItem("book", false, true, 'key'); + let xml = await API.createAttachmentItem("imported_url", [], key, true, 'atom'); + let data = API.parseDataFromAtomEntry(xml); + let key1 = data.key; + + let content = "Here is some full-text content"; + + let response = await API.userPut( + config.userID, + `items/${key1}/fulltext?key=${config.apiKey}`, + JSON.stringify({ + content: content + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 204); + let contentVersion1 = response.headers["last-modified-version"][0]; + assert.isAbove(parseInt(contentVersion1), 0); + + // And another + key = await API.createItem("book", false, true, 'key'); + xml = await API.createAttachmentItem("imported_url", [], key, true, 'atom'); + data = API.parseDataFromAtomEntry(xml); + let key2 = data.key; + + response = await API.userPut( + config.userID, + `items/${key2}/fulltext?key=${config.apiKey}`, + JSON.stringify({ + content: content + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 204); + let contentVersion2 = response.headers["last-modified-version"][0]; + assert.isAbove(parseInt(contentVersion2), 0); + + // Get newer one + response = await API.userGet( + config.userID, + `fulltext?key=${config.apiKey}&newer=${contentVersion1}` + ); + Helpers.assertStatusCode(response, 200); + assert.equal("application/json", response.headers["content-type"][0]); + assert.equal(contentVersion2, response.headers["last-modified-version"][0]); + let json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json), 1); + assert.property(json, key2); + assert.equal(contentVersion2, json[key2]); + + // Get both with newer=0 + response = await API.userGet( + config.userID, + `fulltext?key=${config.apiKey}&newer=0` + ); + Helpers.assertStatusCode(response, 200); + assert.equal("application/json", response.headers["content-type"][0]); + json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json), 2); + assert.property(json, key1); + assert.equal(contentVersion1, json[key1]); + assert.property(json, key2); + assert.equal(contentVersion2, json[key2]); + }); + + //Requires ES + it('testSearchItemContent', async function () { + let key = await API.createItem("book", [], this, 'key'); + let xml = await API.createAttachmentItem("imported_url", [], key, this, 'atom'); + let data = API.parseDataFromAtomEntry(xml); + + let response = await API.userGet( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey + ); + Helpers.assert404(response); + + let content = "Here is some unique full-text content"; + let pages = 50; + + // Store content + response = await API.userPut( + config.userID, + "items/" + data.key + "/fulltext?key=" + config.apiKey, + JSON.stringify({ + content: content, + indexedPages: pages, + totalPages: pages + }), + { "Content-Type": "application/json" } + ); + + Helpers.assert204(response); + + // Local fake-invoke of lambda function that indexes pdf + if (config.isLocalRun) { + const s3Result = await s3Client.send(new GetObjectCommand({ Bucket: config.s3Bucket, Key: `${config.userID}/${data.key}` })); + + const event = { + eventName: "ObjectCreated", + s3: { + bucket: { + name: config.s3Bucket + }, + object: { + key: `${config.userID}/${data.key}`, + eTag: s3Result.ETag.slice(1, -1) + } + }, + + }; + await s3({ Records: [event] }); + } + + // Wait for indexing via Lambda + await new Promise(resolve => setTimeout(resolve, 6000)); + + // Search for a word + response = await API.userGet( + config.userID, + "items?q=unique&qmode=everything&format=keys&key=" + config.apiKey + ); + Helpers.assert200(response); + Helpers.assertEquals(data.key, response.data.trim()); + + // Search for a phrase + response = await API.userGet( + config.userID, + "items?q=unique%20full-text&qmode=everything&format=keys&key=" + config.apiKey + ); + Helpers.assert200(response); + Helpers.assertEquals(data.key, response.data.trim()); + + + // Search for nonexistent word + response = await API.userGet( + config.userID, + "items?q=nothing&qmode=everything&format=keys&key=" + config.apiKey + ); + Helpers.assert200(response); + Helpers.assertEquals("", response.data.trim()); + }); + + it('testDeleteItemContent', async function () { + const key = await API.createItem('book', false, true, 'key'); + const xml = await API.createAttachmentItem('imported_file', [], key, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + + const content = 'Ыюм мютат дэбетиз конвынёры эю, ку мэль жкрипта трактатоз.\nПро ут чтэт эрепюят граэкйж, дуо нэ выро рыкючабо пырикюлёз.'; + + // Store content + let response = await API.userPut( + config.userID, + `items/${data.key}/fulltext?key=${config.apiKey}`, + JSON.stringify({ + content: content, + indexedPages: 50 + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 204); + const contentVersion = response.headers['last-modified-version'][0]; + + // Retrieve it + response = await API.userGet( + config.userID, + `items/${data.key}/fulltext?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 200); + let json = JSON.parse(response.data); + assert.equal(json.content, content); + assert.equal(json.indexedPages, 50); + + // Set to empty string + response = await API.userPut( + config.userID, + `items/${data.key}/fulltext?key=${config.apiKey}`, + JSON.stringify({ + content: "" + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 204); + assert.isAbove(parseInt(response.headers['last-modified-version'][0]), parseInt(contentVersion)); + + // Make sure it's gone + response = await API.userGet( + config.userID, + `items/${data.key}/fulltext?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 200); + json = JSON.parse(response.data); + assert.equal(json.content, ""); + assert.notProperty(json, "indexedPages"); + }); +}); diff --git a/tests/remote_js/test/2/generalTest.js b/tests/remote_js/test/2/generalTest.js new file mode 100644 index 00000000..12efce74 --- /dev/null +++ b/tests/remote_js/test/2/generalTest.js @@ -0,0 +1,66 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + + +describe('GeneralTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + it('testInvalidCharacters', async function () { + const data = { + title: "A" + String.fromCharCode(0) + "A", + creators: [ + { + creatorType: "author", + name: "B" + String.fromCharCode(1) + "B" + } + ], + tags: [ + { + tag: "C" + String.fromCharCode(2) + "C" + } + ] + }; + const xml = await API.createItem("book", data, this, 'atom'); + const parsedData = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(parsedData.content); + assert.equal("AA", json.title); + assert.equal("BB", json.creators[0].name); + assert.equal("CC", json.tags[0].tag); + }); + + it('testZoteroWriteToken', async function () { + const json = await API.getItemTemplate('book'); + const token = Helpers.uniqueToken(); + + let response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ items: [json] }), + { 'Content-Type': 'application/json', 'Zotero-Write-Token': token } + ); + + Helpers.assertStatusCode(response, 200); + Helpers.assert200ForObject(response); + + response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ items: [json] }), + { 'Content-Type': 'application/json', 'Zotero-Write-Token': token } + ); + + Helpers.assertStatusCode(response, 412); + }); +}); diff --git a/tests/remote_js/test/2/groupTest.js b/tests/remote_js/test/2/groupTest.js new file mode 100644 index 00000000..f6bc5775 --- /dev/null +++ b/tests/remote_js/test/2/groupTest.js @@ -0,0 +1,125 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After, resetGroups } = require("../shared.js"); +const { JSDOM } = require("jsdom"); + +describe('GroupTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + await resetGroups(); + }); + + after(async function () { + await API2After(); + }); + + /** + * Changing a group's metadata should change its ETag + */ + it('testUpdateMetadata', async function () { + const response = await API.userGet( + config.userID, + "groups?fq=GroupType:PublicOpen&content=json&key=" + config.apiKey + ); + Helpers.assertStatusCode(response, 200); + + // Get group API URI and ETag + const xml = API.getXMLFromResponse(response); + const groupID = Helpers.xpathEval(xml, "//atom:entry/zapi:groupID"); + let urlComponent = Helpers.xpathEval(xml, "//atom:entry/atom:link[@rel='self']", true); + let url = urlComponent.getAttribute("href"); + url = url.replace(config.apiURLPrefix, ''); + const etagComponent = Helpers.xpathEval(xml, "//atom:entry/atom:content", true); + const etag = etagComponent.getAttribute("etag"); + + // Make sure format=etags returns the same ETag + const response2 = await API.userGet( + config.userID, + "groups?format=etags&key=" + config.apiKey + ); + Helpers.assertStatusCode(response2, 200); + const json = JSON.parse(response2.data); + assert.equal(etag, json[groupID]); + + // Update group metadata + const jsonBody = JSON.parse(Helpers.xpathEval(xml, "//atom:entry/atom:content")); + const xmlDoc = new JSDOM(""); + const groupXML = xmlDoc.window.document.getElementsByTagName("group")[0]; + var name, description, urlField; + for (const [key, value] of Object.entries(jsonBody)) { + switch (key) { + case 'id': + case 'members': + continue; + + case 'name': { + name = "My Test Group " + Math.floor(Math.random() * 10001); + groupXML.setAttribute("name", name); + break; + } + + + case 'description': { + description = "This is a test description " + Math.floor(Math.random() * 10001); + const newNode = xmlDoc.window.document.createElement(key); + newNode.innerHTML = description; + groupXML.appendChild(newNode); + break; + } + + + case 'url': { + urlField = "http://example.com/" + Math.floor(Math.random() * 10001); + const newNode = xmlDoc.window.document.createElement(key); + newNode.innerHTML = urlField; + groupXML.appendChild(newNode); + break; + } + + + default: + groupXML.setAttributeNS(null, key, value); + } + } + + const response3 = await API.put( + url, + groupXML.outerHTML, + { "Content-Type": "text/xml" }, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + Helpers.assertStatusCode(response3, 200); + const xml2 = API.getXMLFromResponse(response3); + const nameFromGroup = xml2.documentElement.getElementsByTagName("title")[0].innerHTML; + assert.equal(name, nameFromGroup); + + const response4 = await API.userGet( + config.userID, + `groups?format=etags&key=${config.apiKey}` + ); + Helpers.assertStatusCode(response4, 200); + const json2 = JSON.parse(response4.data); + const newETag = json2[groupID]; + assert.notEqual(etag, newETag); + + // Check ETag header on individual group request + const response5 = await API.groupGet( + groupID, + "?content=json&key=" + config.apiKey + ); + Helpers.assertStatusCode(response5, 200); + assert.equal(newETag, response5.headers.etag[0]); + const json3 = JSON.parse(API.getContentFromResponse(response5)); + assert.equal(name, json3.name); + assert.equal(description, json3.description); + assert.equal(urlField, json3.url); + }); +}); diff --git a/tests/remote_js/test/2/itemsTest.js b/tests/remote_js/test/2/itemsTest.js new file mode 100644 index 00000000..a022168f --- /dev/null +++ b/tests/remote_js/test/2/itemsTest.js @@ -0,0 +1,1088 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); +const {post}=require('../../httpHandler.js'); + +describe('ItemsTests', function () { + this.timeout(config.timeout * 2); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + const testNewEmptyBookItem = async () => { + const xml = await API.createItem("book", false, true); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + assert.equal(json.itemType, "book"); + return json; + }; + + it('testNewEmptyBookItemMultiple', async function () { + const json = await API.getItemTemplate("book"); + + const data = []; + json.title = "A"; + data.push(json); + const json2 = Object.assign({}, json); + json2.title = "B"; + data.push(json2); + const json3 = Object.assign({}, json); + json3.title = "C"; + data.push(json3); + + const response = await API.postItems(data); + Helpers.assertStatusCode(response, 200); + const jsonResponse = API.getJSONFromResponse(response); + const successArray = Object.keys(jsonResponse.success).map(key => jsonResponse.success[key]); + const xml = await API.getItemXML(successArray, true); + const contents = Helpers.xpathEval(xml, '/atom:feed/atom:entry/atom:content', false, true); + + let content = JSON.parse(contents[0]); + assert.equal(content.title, "A"); + content = JSON.parse(contents[1]); + assert.equal(content.title, "B"); + content = JSON.parse(contents[2]); + assert.equal(content.title, "C"); + }); + + it('testEditBookItem', async function () { + const newBookItem = await testNewEmptyBookItem(); + const key = newBookItem.itemKey; + const version = newBookItem.itemVersion; + + const newTitle = 'New Title'; + const numPages = 100; + const creatorType = 'author'; + const firstName = 'Firstname'; + const lastName = 'Lastname'; + + newBookItem.title = newTitle; + newBookItem.numPages = numPages; + newBookItem.creators.push({ + creatorType: creatorType, + firstName: firstName, + lastName: lastName + }); + + const response = await API.userPut( + config.userID, + `items/${key}?key=${config.apiKey}`, + JSON.stringify(newBookItem), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': version + } + ); + Helpers.assertStatusCode(response, 204); + + const xml = await API.getItemXML(key); + const data = API.parseDataFromAtomEntry(xml); + const updatedJson = JSON.parse(data.content); + + assert.equal(newTitle, updatedJson.title); + assert.equal(numPages, updatedJson.numPages); + assert.equal(creatorType, updatedJson.creators[0].creatorType); + assert.equal(firstName, updatedJson.creators[0].firstName); + assert.equal(lastName, updatedJson.creators[0].lastName); + }); + + it('testDateModified', async function () { + const objectType = 'item'; + const objectTypePlural = API.getPluralObjectType(objectType); + // In case this is ever extended to other objects + let xml; + let itemData; + switch (objectType) { + case 'item': + itemData = { + title: "Test" + }; + xml = await API.createItem("videoRecording", itemData, this, 'atom'); + break; + } + + const data = API.parseDataFromAtomEntry(xml); + const objectKey = data.key; + let json = JSON.parse(data.content); + const dateModified1 = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + + // Make sure we're in the next second + await new Promise(resolve => setTimeout(resolve, 1000)); + + // + // If no explicit dateModified, use current timestamp + // + json.title = 'Test 2'; + let response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + switch (objectType) { + case 'item': + xml = await API.getItemXML(objectKey); + break; + } + + const dateModified2 = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + assert.notEqual(dateModified1, dateModified2); + json = JSON.parse(API.parseDataFromAtomEntry(xml).content); + + // Make sure we're in the next second + await new Promise(resolve => setTimeout(resolve, 1000)); + + // + // If existing dateModified, use current timestamp + // + json.title = 'Test 3'; + json.dateModified = dateModified2; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}? key=${config.apiKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + switch (objectType) { + case 'item': + xml = await API.getItemXML(objectKey); + break; + } + + const dateModified3 = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + assert.notEqual(dateModified2, dateModified3); + json = JSON.parse(API.parseDataFromAtomEntry(xml).content); + + // + // If explicit dateModified, use that + // + const newDateModified = "2013-03-03T21:33:53Z"; + json.title = 'Test 4'; + json.dateModified = newDateModified; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}? key=${config.apiKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + switch (objectType) { + case 'item': + xml = await API.getItemXML(objectKey); + break; + } + const dateModified4 = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + assert.equal(newDateModified, dateModified4); + }); + + it('testDateAccessedInvalid', async function () { + const date = 'February 1, 2014'; + const xml = await API.createItem("book", { accessDate: date }, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + // Invalid dates should be ignored + assert.equal(json.accessDate, ''); + }); + + it('testChangeItemType', async function () { + const json = await API.getItemTemplate("book"); + json.title = "Foo"; + json.numPages = 100; + + const response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json], + }), + { "Content-Type": "application/json" } + ); + + const key = API.getFirstSuccessKeyFromResponse(response); + const xml = await API.getItemXML(key, true); + const data = API.parseDataFromAtomEntry(xml); + const version = data.version; + const json1 = JSON.parse(data.content); + + const json2 = await API.getItemTemplate("bookSection"); + delete json2.attachments; + delete json2.notes; + + Object.entries(json2).forEach(([field, _]) => { + if (field !== "itemType" && json1[field]) { + json2[field] = json1[field]; + } + }); + + const response2 = await API.userPut( + config.userID, + "items/" + key + "?key=" + config.apiKey, + JSON.stringify(json2), + { "Content-Type": "application/json", "If-Unmodified-Since-Version": version } + ); + + Helpers.assertStatusCode(response2, 204); + + const xml2 = await API.getItemXML(key); + const data2 = API.parseDataFromAtomEntry(xml2); + const json3 = JSON.parse(data2.content); + + assert.equal(json3.itemType, "bookSection"); + assert.equal(json3.title, "Foo"); + assert.notProperty(json3, "numPages"); + }); + + it('testModifyItemPartial', async function () { + const itemData = { + title: "Test" + }; + const xml = await API.createItem("book", itemData, this, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + let itemVersion = json.itemVersion; + + const patch = async (itemKey, itemVersion, itemData, newData) => { + for (const field in newData) { + itemData[field] = newData[field]; + } + const response = await API.userPatch( + config.userID, + "items/" + itemKey + "?key=" + config.apiKey, + JSON.stringify(newData), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion + } + ); + Helpers.assertStatusCode(response, 204); + const xml = await API.getItemXML(itemKey); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + + for (const field in itemData) { + assert.deepEqual(itemData[field], json[field]); + } + const headerVersion = parseInt(response.headers["last-modified-version"][0]); + assert.isAbove(headerVersion, itemVersion); + assert.equal(json.itemVersion, headerVersion); + + return headerVersion; + }; + + let newData = { + date: "2013" + }; + itemVersion = await patch(data.key, itemVersion, itemData, newData); + + newData = { + title: "" + }; + itemVersion = await patch(data.key, itemVersion, itemData, newData); + + newData = { + tags: [ + { tag: "Foo" } + ] + }; + itemVersion = await patch(data.key, itemVersion, itemData, newData); + + newData = { + tags: [] + }; + itemVersion = await patch(data.key, itemVersion, itemData, newData); + + const key = await API.createCollection('Test', false, this, 'key'); + newData = { + collections: [key] + }; + itemVersion = await patch(data.key, itemVersion, itemData, newData); + + newData = { + collections: [] + }; + await patch(data.key, itemVersion, itemData, newData); + }); + + it('testNewComputerProgramItem', async function () { + const xml = await API.createItem('computerProgram', false, true); + const data = API.parseDataFromAtomEntry(xml); + const key = data.key; + const json = JSON.parse(data.content); + assert.equal(json.itemType, 'computerProgram'); + + const version = '1.0'; + json.version = version; + + const response = await API.userPut( + config.userID, + `items/${key}?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json", "If-Unmodified-Since-Version": data.version } + ); + Helpers.assertStatusCode(response, 204); + + const xml2 = await API.getItemXML(key); + const data2 = API.parseDataFromAtomEntry(xml2); + const json2 = JSON.parse(data2.content); + assert.equal(json2.version, version); + + // 'versionNumber' from v3 should work too + delete json2.version; + const version2 = '1.1'; + json2.versionNumber = version2; + const response2 = await API.userPut( + config.userID, + `items/${key}?key=${config.apiKey}`, + JSON.stringify(json2), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response2, 204); + + const xml3 = await API.getItemXML(key); + const data3 = API.parseDataFromAtomEntry(xml3); + const json3 = JSON.parse(data3.content); + assert.equal(json3.version, version2); + }); + + it('testNewInvalidBookItem', async function () { + const json = await API.getItemTemplate("book"); + + // Missing item type + const json2 = { ...json }; + delete json2.itemType; + let response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json2] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "'itemType' property not provided" }); + + // contentType on non-attachment + const json3 = { ...json }; + json3.contentType = "text/html"; + response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json3] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "'contentType' is valid only for attachment items" }); + }); + + it('testEditTopLevelNote', async function () { + const xml = await API.createNoteItem("

Test

", null, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + const noteText = "

Test Test

"; + json.note = noteText; + const response = await API.userPut( + config.userID, + `items/${data.key}?key=` + config.apiKey, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + const response2 = await API.userGet( + config.userID, + `items/${data.key}?key=` + config.apiKey + "&content=json" + ); + Helpers.assertStatusCode(response2, 200); + const xml2 = API.getXMLFromResponse(response2); + const data2 = API.parseDataFromAtomEntry(xml2); + const json2 = JSON.parse(data2.content); + assert.equal(json2.note, noteText); + }); + + it('testEditChildNote', async function () { + const key = await API.createItem("book", { title: "Test" }, true, 'key'); + const xml = await API.createNoteItem("

Test

", key, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + const noteText = "

Test Test

"; + json.note = noteText; + const response1 = await API.userPut( + config.userID, + "items/" + data.key + "?key=" + config.apiKey, + JSON.stringify(json) + ); + assert.equal(response1.status, 204); + const response2 = await API.userGet( + config.userID, + "items/" + data.key + "?key=" + config.apiKey + "&content=json" + ); + Helpers.assertStatusCode(response2, 200); + const xml2 = API.getXMLFromResponse(response2); + const data2 = API.parseDataFromAtomEntry(xml2); + const json2 = JSON.parse(data2.content); + assert.equal(json2.note, noteText); + }); + + it('testEditTitleWithCollectionInMultipleMode', async function () { + const collectionKey = await API.createCollection('Test', false, true, 'key'); + let xml = await API.createItem('book', { + title: 'A', + collections: [ + collectionKey, + ], + }, true, 'atom'); + let data = API.parseDataFromAtomEntry(xml); + data = JSON.parse(data.content); + const version = data.itemVersion; + data.title = 'B'; + const response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, JSON.stringify({ + items: [data], + }), + ); + Helpers.assert200ForObject(response, 200); + xml = await API.getItemXML(data.itemKey); + data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + assert.equal(json.title, 'B'); + assert.isAbove(json.itemVersion, version); + }); + + it('testEditTitleWithTagInMultipleMode', async function () { + const tag1 = { + tag: 'foo', + type: 1, + }; + const tag2 = { + tag: 'bar', + }; + + const xml = await API.createItem('book', { + title: 'A', + tags: [tag1], + }, true, 'atom'); + + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + assert.equal(json.tags.length, 1); + assert.deepEqual(json.tags[0], tag1); + + const version = json.itemVersion; + json.title = 'B'; + json.tags.push(tag2); + + const response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json], + }), + ); + Helpers.assert200ForObject(response); + const xml2 = await API.getItemXML(json.itemKey); + const data2 = API.parseDataFromAtomEntry(xml2); + const json2 = JSON.parse(data2.content); + assert.equal(json2.title, 'B'); + assert.isAbove(json2.itemVersion, version); + assert.equal(json2.tags.length, 2); + assert.deepEqual(json2.tags, [tag2, tag1]); + }); + + it('testNewTopLevelImportedFileAttachment', async function () { + const response = await API.get("items/new?itemType=attachment&linkMode=imported_file"); + const json = JSON.parse(response.data); + const userPostResponse = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(userPostResponse, 200); + }); + + //Disabled -- see note at Zotero_Item::checkTopLevelAttachment() + it('testNewInvalidTopLevelAttachment', async function () { + this.skip(); + }); + + it('testNewEmptyLinkAttachmentItemWithItemKey', async function () { + const key = await API.createItem("book", false, true, 'key'); + await API.createAttachmentItem("linked_url", [], key, true, 'atom'); + + let response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + let json = JSON.parse(response.data); + json.parentItem = key; + + json.itemKey = Helpers.uniqueID(); + json.itemVersion = 0; + + response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + }); + + const testNewEmptyImportedURLAttachmentItem = async () => { + const key = await API.createItem('book', false, true, 'key'); + const xml = await API.createAttachmentItem('imported_url', [], key, true, 'atom'); + return API.parseDataFromAtomEntry(xml); + }; + + it('testEditEmptyImportedURLAttachmentItem', async function () { + const newItemData = await testNewEmptyImportedURLAttachmentItem(); + const key = newItemData.key; + const version = newItemData.version; + const json = JSON.parse(newItemData.content); + + const response = await API.userPut( + config.userID, + `items/${key}?key=${config.apiKey}`, + JSON.stringify(json), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': version + } + ); + Helpers.assertStatusCode(response, 204); + + const xml = await API.getItemXML(key); + const data = API.parseDataFromAtomEntry(xml); + // Item Shouldn't be changed + assert.equal(version, data.version); + }); + + const testEditEmptyLinkAttachmentItem = async () => { + const key = await API.createItem('book', false, true, 'key'); + const xml = await API.createAttachmentItem('linked_url', [], key, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + + const updatedKey = data.key; + const version = data.version; + const json = JSON.parse(data.content); + + const response = await API.userPut( + config.userID, + `items/${updatedKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assertStatusCode(response, 204); + + const newXml = await API.getItemXML(updatedKey); + const newData = API.parseDataFromAtomEntry(newXml); + // Item shouldn't change + assert.equal(version, newData.version); + return newData; + }; + + it('testEditLinkAttachmentItem', async function () { + const newItemData = await testEditEmptyLinkAttachmentItem(); + const key = newItemData.key; + const version = newItemData.version; + const json = JSON.parse(newItemData.content); + + const contentType = "text/xml"; + const charset = "utf-8"; + + json.contentType = contentType; + json.charset = charset; + + const response = await API.userPut( + config.userID, + `items/${key}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + + Helpers.assertStatusCode(response, 204); + + const xml = await API.getItemXML(key); + const data = API.parseDataFromAtomEntry(xml); + const parsedJson = JSON.parse(data.content); + + assert.equal(parsedJson.contentType, contentType); + assert.equal(parsedJson.charset, charset); + }); + + it('testEditAttachmentUpdatedTimestamp', async function () { + const xml = await API.createAttachmentItem("linked_file", [], false, true); + const data = API.parseDataFromAtomEntry(xml); + const atomUpdated = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + const json = JSON.parse(data.content); + json.note = "Test"; + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const response = await API.userPut( + config.userID, + `items/${data.key}?key=${config.apiKey}`, + JSON.stringify(json), + { "If-Unmodified-Since-Version": data.version } + ); + Helpers.assertStatusCode(response, 204); + + const xml2 = await API.getItemXML(data.key); + const atomUpdated2 = Helpers.xpathEval(xml2, '//atom:entry/atom:updated'); + assert.notEqual(atomUpdated2, atomUpdated); + }); + + it('testNewAttachmentItemInvalidLinkMode', async function () { + const response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + const json = JSON.parse(response.data); + + // Invalid linkMode + json.linkMode = "invalidName"; + const newResponse = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(newResponse, { message: "'invalidName' is not a valid linkMode" }); + + // Missing linkMode + delete json.linkMode; + const missingResponse = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(missingResponse, { message: "'linkMode' property not provided" }); + }); + it('testNewAttachmentItemMD5OnLinkedURL', async function () { + const newItemData = await testNewEmptyBookItem(); + const parentKey = newItemData.key; + + const response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + const json = JSON.parse(response.data); + json.parentItem = parentKey; + + json.md5 = "c7487a750a97722ae1878ed46b215ebe"; + const postResponse = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(postResponse, { message: "'md5' is valid only for imported and embedded-image attachments" }); + }); + it('testNewAttachmentItemModTimeOnLinkedURL', async function () { + const newItemData = await testNewEmptyBookItem(); + const parentKey = newItemData.key; + + const response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + const json = JSON.parse(response.data); + json.parentItem = parentKey; + + json.mtime = "1332807793000"; + const postResponse = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(postResponse, { message: "'mtime' is valid only for imported and embedded-image attachments" }); + }); + it('testMappedCreatorTypes', async function () { + const json = { + items: [ + { + itemType: 'presentation', + title: 'Test', + creators: [ + { + creatorType: "author", + name: "Foo" + } + ] + }, + { + itemType: 'presentation', + title: 'Test', + creators: [ + { + creatorType: "editor", + name: "Foo" + } + ] + } + ] + }; + const response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify(json) + ); + // 'author' gets mapped automatically, others dont + Helpers.assert400ForObject(response, { index: 1 }); + Helpers.assert200ForObject(response); + }); + + it('testNumChildren', async function () { + let xml = await API.createItem("book", false, true); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'), 0); + const data = API.parseDataFromAtomEntry(xml); + const key = data.key; + + await API.createAttachmentItem("linked_url", [], key, true, 'key'); + + let response = await API.userGet( + config.userID, + `items/${key}?key=${config.apiKey}&content=json` + ); + xml = API.getXMLFromResponse(response); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'), 1); + + await API.createNoteItem("Test", key, true, 'key'); + + response = await API.userGet( + config.userID, + `items/${key}?key=${config.apiKey}&content=json` + ); + xml = API.getXMLFromResponse(response); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'), 2); + }); + + it('testTop', async function () { + await API.userClear(config.userID); + + const collectionKey = await API.createCollection('Test', false, this, 'key'); + + const parentTitle1 = "Parent Title"; + const childTitle1 = "This is a Test Title"; + const parentTitle2 = "Another Parent Title"; + const parentTitle3 = "Yet Another Parent Title"; + const noteText = "This is a sample note."; + const parentTitleSearch = "title"; + const childTitleSearch = "test"; + const dates = ["2013", "January 3, 2010", ""]; + const orderedDates = [dates[2], dates[1], dates[0]]; + const itemTypes = ["journalArticle", "newspaperArticle", "book"]; + + const parentKeys = []; + const childKeys = []; + + parentKeys.push(await API.createItem(itemTypes[0], { + title: parentTitle1, + date: dates[0], + collections: [ + collectionKey + ] + }, this, 'key')); + + childKeys.push(await API.createAttachmentItem("linked_url", { + title: childTitle1 + }, parentKeys[0], this, 'key')); + + parentKeys.push(await API.createItem(itemTypes[1], { + title: parentTitle2, + date: dates[1] + }, this, 'key')); + + childKeys.push(await API.createNoteItem(noteText, parentKeys[1], this, 'key')); + + parentKeys.push(await API.createItem(itemTypes[2], { + title: parentTitle3 + }, this, 'key')); + + childKeys.push(await API.createAttachmentItem("linked_url", { + title: childTitle1, + deleted: true + }, parentKeys[parentKeys.length - 1], this, 'key')); + + const deletedKey = await API.createItem("book", { + title: "This is a deleted item", + deleted: true, + }, this, 'key'); + + await API.createNoteItem("This is a child note of a deleted item.", deletedKey, this, 'key'); + + const top = async (url, expectedResults = -1) => { + const response = await API.userGet(config.userID, url); + Helpers.assertStatusCode(response, 200); + if (expectedResults !== -1) { + Helpers.assertNumResults(response, expectedResults); + } + return response; + }; + + const checkXml = (response, expectedCount = -1, path = '//atom:entry/zapi:key') => { + const xml = API.getXMLFromResponse(response); + const xpath = Helpers.xpathEval(xml, path, false, true); + if (expectedCount !== -1) { + assert.equal(xpath.length, expectedCount); + } + return xpath; + }; + + // /top, Atom + let response = await top(`items/top?key=${config.apiKey}&content=json`, parentKeys.length); + let xpath = await checkXml(response, parentKeys.length); + for (let parentKey of parentKeys) { + assert.include(xpath, parentKey); + } + + // /top, Atom, in collection + response = await top(`collections/${collectionKey}/items/top?key=${config.apiKey}&content=json`, 1); + xpath = await checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, keys + response = await top(`items/top?key=${config.apiKey}&format=keys`); + let keys = response.data.trim().split("\n"); + assert.equal(keys.length, parentKeys.length); + for (let parentKey of parentKeys) { + assert.include(keys, parentKey); + } + + // /top, keys, in collection + response = await top(`collections/${collectionKey}/items/top?key=${config.apiKey}&format=keys`); + assert.equal(response.data.trim(), parentKeys[0]); + + // /top with itemKey for parent, Atom + response = await top(`items/top?key=${config.apiKey}&content=json&itemKey=${parentKeys[0]}`, 1); + xpath = await checkXml(response); + assert.equal(parentKeys[0], xpath.shift()); + + // /top with itemKey for parent, Atom, in collection + response = await top(`collections/${collectionKey}/items/top?key=${config.apiKey}&content=json&itemKey=${parentKeys[0]}`, 1); + xpath = await checkXml(response); + assert.equal(parentKeys[0], xpath.shift()); + + // /top with itemKey for parent, keys + response = await top(`items/top?key=${config.apiKey}&format=keys&itemKey=${parentKeys[0]}`); + assert.equal(parentKeys[0], response.data.trim()); + + // /top with itemKey for parent, keys, in collection + response = await top(`collections/${collectionKey}/items/top?key=${config.apiKey}&format=keys&itemKey=${parentKeys[0]}`); + assert.equal(parentKeys[0], response.data.trim()); + + // /top with itemKey for child, Atom + response = await top(`items/top?key=${config.apiKey}&content=json&itemKey=${childKeys[0]}`, 1); + xpath = await checkXml(response); + assert.equal(parentKeys[0], xpath.shift()); + + // /top with itemKey for child, keys + response = await top(`items/top?key=${config.apiKey}&format=keys&itemKey=${childKeys[0]}`); + assert.equal(parentKeys[0], response.data.trim()); + + // /top, Atom, with q for all items + response = await top(`items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}`, parentKeys.length); + xpath = await checkXml(response, parentKeys.length); + for (let parentKey of parentKeys) { + assert.include(xpath, parentKey); + } + + // /top, Atom, in collection, with q for all items + response = await top(`collections/${collectionKey}/items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}`, 1); + xpath = await checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, Atom, with q for child item + response = await top(`items/top?key=${config.apiKey}&content=json&q=${childTitleSearch}`, 1); + xpath = checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, Atom, in collection, with q for child item + response = await top(`collections/${collectionKey}/items/top?key=${config.apiKey}&content=json&q=${childTitleSearch}`, 1); + xpath = checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, Atom, with q for all items, ordered by title + response = await top(`items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}&order=title`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/atom:title'); + + let orderedTitles = [parentTitle1, parentTitle2, parentTitle3].sort(); + let orderedResults = xpath.map(val => String(val)); + assert.deepEqual(orderedTitles, orderedResults); + + // /top, Atom, with q for all items, ordered by date asc + response = await top(`items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}&order=date&sort=asc`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/atom:content'); + orderedResults = xpath.map(val => JSON.parse(val).date); + assert.deepEqual(orderedDates, orderedResults); + + // /top, Atom, with q for all items, ordered by date desc + response = await top(`items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}&order=date&sort=desc`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/atom:content'); + let orderedDatesReverse = [...orderedDates].reverse(); + orderedResults = xpath.map(val => JSON.parse(val).date); + assert.deepEqual(orderedDatesReverse, orderedResults); + + // /top, Atom, with q for all items, ordered by item type asc + response = await top(`items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}&order=itemType`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/zapi:itemType'); + let orderedItemTypes = [...itemTypes].sort(); + orderedResults = xpath.map(val => String(val)); + assert.deepEqual(orderedItemTypes, orderedResults); + + // /top, Atom, with q for all items, ordered by item type desc + response = await top(`items/top?key=${config.apiKey}&content=json&q=${parentTitleSearch}&order=itemType&sort=desc`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/zapi:itemType'); + orderedItemTypes = [...itemTypes].sort().reverse(); + orderedResults = xpath.map(val => String(val)); + assert.deepEqual(orderedItemTypes, orderedResults); + }); + + it('testParentItem', async function () { + let xml = await API.createItem("book", false, true); + let data = API.parseDataFromAtomEntry(xml); + let json = JSON.parse(data.content); + let parentKey = data.key; + let parentVersion = data.version; + + xml = await API.createAttachmentItem("linked_url", [], parentKey, true); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + let childKey = data.key; + let childVersion = data.version; + + assert.ok(json.parentItem); + assert.equal(parentKey, json.parentItem); + + // Remove the parent, making the child a standalone attachment + delete json.parentItem; + + // Remove version property, to test header + delete json.itemVersion; + + // The parent item version should have been updated when a child + // was added, so this should fail + let response = await API.userPut( + config.userID, + `items/${childKey}?key=${config.apiKey}`, + JSON.stringify(json), + { "If-Unmodified-Since-Version": parentVersion } + ); + Helpers.assertStatusCode(response, 412); + + response = await API.userPut( + config.userID, + `items/${childKey}?key=${config.apiKey}`, + JSON.stringify(json), + { "If-Unmodified-Since-Version": childVersion } + ); + Helpers.assertStatusCode(response, 204); + + xml = await API.getItemXML(childKey); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + assert.notExists(json.parentItem); + }); + + it('testParentItemPatch', async function () { + let xml = await API.createItem("book", false, true); + let data = API.parseDataFromAtomEntry(xml); + let json = JSON.parse(data.content); + const parentKey = data.key; + + xml = await API.createAttachmentItem("linked_url", [], parentKey, true); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + const childKey = data.key; + const childVersion = data.version; + + assert.ok(json.parentItem); + assert.equal(parentKey, json.parentItem); + + const json3 = { + title: 'Test' + }; + + // With PATCH, parent shouldn't be removed even though unspecified + const response = await API.userPatch( + config.userID, + `items/${childKey}?key=${config.apiKey}`, + JSON.stringify(json3), + { "If-Unmodified-Since-Version": childVersion }, + ); + + Helpers.assertStatusCode(response, 204); + + xml = await API.getItemXML(childKey); + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + assert.ok(json.parentItem); + }); + + it('testDate', async function () { + const date = "Sept 18, 2012"; + + const xml = await API.createItem("book", { date: date }, true); + const data = API.parseDataFromAtomEntry(xml); + const key = data.key; + + const response = await API.userGet( + config.userID, + `items/${key}?key=${config.apiKey}&content=json` + ); + const xmlResponse = API.getXMLFromResponse(response); + const dataResponse = API.parseDataFromAtomEntry(xmlResponse); + const json = JSON.parse(dataResponse.content); + assert.equal(date, json.date); + + assert.equal(Helpers.xpathEval(xmlResponse, '//atom:entry/zapi:year'), '2012'); + }); + + it('testUnicodeTitle', async function () { + const title = "Tést"; + + const xml = await API.createItem("book", { title }, true); + const data = API.parseDataFromAtomEntry(xml); + const key = data.key; + + // Test entry + let response = await API.userGet( + config.userID, + `items/${key}?key=${config.apiKey}&content=json` + ); + let xmlResponse = API.getXMLFromResponse(response); + assert.equal(xmlResponse.getElementsByTagName("title")[0].innerHTML, "Tést"); + + // Test feed + response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&content=json` + ); + xmlResponse = API.getXMLFromResponse(response); + + let titleFound = false; + for (var node of xmlResponse.getElementsByTagName("title")) { + if (node.innerHTML == title) { + titleFound = true; + } + } + assert.ok(titleFound); + }); +}); diff --git a/tests/remote_js/test/2/mappingsTest.js b/tests/remote_js/test/2/mappingsTest.js new file mode 100644 index 00000000..f5d22683 --- /dev/null +++ b/tests/remote_js/test/2/mappingsTest.js @@ -0,0 +1,64 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('MappingsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + it('testNewItem', async function () { + let response = await API.get("items/new?itemType=invalidItemType"); + Helpers.assertStatusCode(response, 400); + + response = await API.get("items/new?itemType=book"); + Helpers.assertStatusCode(response, 200); + assert.equal(response.headers['content-type'][0], 'application/json'); + const json = JSON.parse(response.data); + assert.equal(json.itemType, 'book'); + }); + + it('testNewItemAttachment', async function () { + let response = await API.get('items/new?itemType=attachment'); + Helpers.assertStatusCode(response, 400); + + response = await API.get('items/new?itemType=attachment&linkMode=invalidLinkMode'); + Helpers.assertStatusCode(response, 400); + + response = await API.get('items/new?itemType=attachment&linkMode=linked_url'); + Helpers.assertStatusCode(response, 200); + const json1 = JSON.parse(response.data); + assert.isNotNull(json1); + assert.property(json1, 'url'); + + response = await API.get('items/new?itemType=attachment&linkMode=linked_file'); + Helpers.assertStatusCode(response, 200); + const json2 = JSON.parse(response.data); + assert.isNotNull(json2); + assert.notProperty(json2, 'url'); + }); + + it('testComputerProgramVersion', async function () { + let response = await API.get("items/new?itemType=computerProgram"); + Helpers.assertStatusCode(response, 200); + let json = JSON.parse(response.data); + assert.property(json, 'version'); + assert.notProperty(json, 'versionNumber'); + + response = await API.get("itemTypeFields?itemType=computerProgram"); + Helpers.assertStatusCode(response, 200); + json = JSON.parse(response.data); + let fields = json.map(val => val.field); + assert.include(fields, 'version'); + assert.notInclude(fields, 'versionNumber'); + }); +}); diff --git a/tests/remote_js/test/2/noteTest.js b/tests/remote_js/test/2/noteTest.js new file mode 100644 index 00000000..34df9be6 --- /dev/null +++ b/tests/remote_js/test/2/noteTest.js @@ -0,0 +1,101 @@ +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require("../../helpers2.js"); +const { API2Before, API2After } = require("../shared.js"); + +describe('NoteTests', function () { + this.timeout(config.timeout); + let content; + let json; + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + beforeEach(async function () { + content = "1234567890".repeat(50001); + json = await API.getItemTemplate("note"); + json.note = content; + }); + + it('testNoteTooLong', async function () { + const response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + const expectedMessage = "Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long"; + Helpers.assert413ForObject(response, { message : expectedMessage }); + }); + + it('testNoteTooLongBlankFirstLines', async function () { + json.note = " \n \n" + content; + + const response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + const expectedMessage = "Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long"; + Helpers.assert413ForObject(response, { message : expectedMessage }); + }); + + it('testNoteTooLongBlankFirstLinesHTML', async function () { + json.note = '\n

 

\n

 

\n' + content; + + const response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + const expectedMessage = "Note '1234567890123456789012345678901234567890123456789012345678901234567890123...' too long"; + Helpers.assert413ForObject(response, { message : expectedMessage }); + }); + + it('testNoteTooLongTitlePlusNewlines', async function () { + json.note = "Full Text:\n\n" + content; + + const response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + const expectedMessage = "Note 'Full Text: 1234567890123456789012345678901234567890123456789012345678901234567...' too long"; + Helpers.assert413ForObject(response, { message : expectedMessage }); + }); + + // All content within HTML tags + it('testNoteTooLongWithinHTMLTags', async function () { + json.note = " \n

"; + + const response = await API.userPost( + config.userID, + "items?key=" + config.apiKey, + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + + const expectedMessage = "Note '<p><!-- 1234567890123456789012345678901234567890123456789012345678901234...' too long"; + Helpers.assert413ForObject(response, { message : expectedMessage }); + }); +}); diff --git a/tests/remote_js/test/2/objectTest.js b/tests/remote_js/test/2/objectTest.js new file mode 100644 index 00000000..81c6da9c --- /dev/null +++ b/tests/remote_js/test/2/objectTest.js @@ -0,0 +1,449 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('ObjectTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + beforeEach(async function() { + await API.userClear(config.userID); + }); + + const _testMultiObjectGet = async (objectType = 'collection') => { + const objectNamePlural = API.getPluralObjectType(objectType); + const keyProp = `${objectType}Key`; + + const keys = []; + switch (objectType) { + case 'collection': + keys.push(await API.createCollection("Name", false, true, 'key')); + keys.push(await API.createCollection("Name", false, true, 'key')); + await API.createCollection("Name", false, true, 'key'); + break; + + case 'item': + keys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + keys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + await API.createItem("book", { title: "Title" }, true, 'key'); + break; + + case 'search': + keys.push(await API.createSearch("Name", 'default', true, 'key')); + keys.push(await API.createSearch("Name", 'default', true, 'key')); + await API.createSearch("Name", 'default', true, 'key'); + break; + } + + let response = await API.userGet( + config.userID, + `${objectNamePlural}?key=${config.apiKey}&${keyProp}=${keys.join(',')}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, keys.length); + + // Trailing comma in itemKey parameter + response = await API.userGet( + config.userID, + `${objectNamePlural}?key=${config.apiKey}&${keyProp}=${keys.join(',')},` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, keys.length); + }; + + const _testSingleObjectDelete = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let xml; + switch (objectType) { + case 'collection': + xml = await API.createCollection('Name', false, true); + break; + case 'item': + xml = await API.createItem('book', { title: 'Title' }, true); + break; + case 'search': + xml = await API.createSearch('Name', 'default', true); + break; + } + + const data = API.parseDataFromAtomEntry(xml); + const objectKey = data.key; + const objectVersion = data.version; + + const responseDelete = await API.userDelete( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + { 'If-Unmodified-Since-Version': objectVersion } + ); + Helpers.assertStatusCode(responseDelete, 204); + + const responseGet = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}` + ); + Helpers.assertStatusCode(responseGet, 404); + }; + + const _testMultiObjectDelete = async (objectType) => { + const objectTypePlural = await API.getPluralObjectType(objectType); + const keyProp = `${objectType}Key`; + + const deleteKeys = []; + const keepKeys = []; + switch (objectType) { + case 'collection': + deleteKeys.push(await API.createCollection("Name", false, true, 'key')); + deleteKeys.push(await API.createCollection("Name", false, true, 'key')); + keepKeys.push(await API.createCollection("Name", false, true, 'key')); + break; + + case 'item': + deleteKeys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + deleteKeys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + keepKeys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + break; + + case 'search': + deleteKeys.push(await API.createSearch("Name", 'default', true, 'key')); + deleteKeys.push(await API.createSearch("Name", 'default', true, 'key')); + keepKeys.push(await API.createSearch("Name", 'default', true, 'key')); + break; + } + + let response = await API.userGet(config.userID, `${objectTypePlural}?key=${config.apiKey}`); + Helpers.assertNumResults(response, deleteKeys.length + keepKeys.length); + + let libraryVersion = response.headers["last-modified-version"]; + + response = await API.userDelete(config.userID, + `${objectTypePlural}?key=${config.apiKey}&${keyProp}=${deleteKeys.join(',')}`, + { "If-Unmodified-Since-Version": libraryVersion } + ); + Helpers.assertStatusCode(response, 204); + libraryVersion = response.headers["last-modified-version"]; + response = await API.userGet(config.userID, `${objectTypePlural}?key=${config.apiKey}`); + Helpers.assertNumResults(response, keepKeys.length); + + response = await API.userGet(config.userID, `${objectTypePlural}?key=${config.apiKey}&${keyProp}=${keepKeys.join(',')}`); + Helpers.assertNumResults(response, keepKeys.length); + + // Add trailing comma to itemKey param, to test key parsing + response = await API.userDelete(config.userID, + `${objectTypePlural}?key=${config.apiKey}&${keyProp}=${keepKeys.join(',')},`, + { "If-Unmodified-Since-Version": libraryVersion }); + Helpers.assertStatusCode(response, 204); + + response = await API.userGet(config.userID, `${objectTypePlural}?key=${config.apiKey}`); + Helpers.assertNumResults(response, 0); + }; + + const _testPartialWriteFailure = async (objectType) => { + await API.userClear(config.userID); + let json; + let conditions = []; + let json1 = { name: "Test" }; + let json2 = { name: "1234567890".repeat(6554) }; + let json3 = { name: "Test" }; + switch (objectType) { + case 'collection': + json1 = { name: "Test" }; + json2 = { name: "1234567890".repeat(6554) }; + json3 = { name: "Test" }; + break; + case 'item': + json1 = await API.getItemTemplate('book'); + json2 = { ...json1 }; + json3 = { ...json1 }; + json2.title = "1234567890".repeat(6554); + break; + case 'search': + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'value' + } + ]; + json1 = { name: "Test", conditions: conditions }; + json2 = { name: "1234567890".repeat(6554), conditions: conditions }; + json3 = { name: "Test", conditions: conditions }; + break; + } + + const response = await API.userPost( + config.userID, + `${API.getPluralObjectType(objectType)}?key=${config.apiKey}`, + JSON.stringify({ + objectTypePlural: [json1, json2, json3] + }), + { "Content-Type": "application/json" }); + + Helpers.assertStatusCode(response, 200); + json = API.getJSONFromResponse(response); + + Helpers.assert200ForObject(response, { index: 0 }); + Helpers.assert413ForObject(response, { index: 1 }); + Helpers.assert200ForObject(response, { index: 2 }); + + const responseKeys = await API.userGet( + config.userID, + `${API.getPluralObjectType(objectType)}?format=keys&key=${config.apiKey}` + ); + + Helpers.assertStatusCode(responseKeys, 200); + const keys = responseKeys.data.trim().split("\n"); + + assert.lengthOf(keys, 2); + json.success.forEach((key) => { + assert.include(keys, key); + }); + }; + + const _testPartialWriteFailureWithUnchanged = async (objectType) => { + await API.userClear(config.userID); + + let objectTypePlural = API.getPluralObjectType(objectType); + let json1, json2, json3, objectData, objectDataContent; + let conditions = []; + + switch (objectType) { + case 'collection': + objectData = await API.createCollection('Test', false, true, 'data'); + objectDataContent = objectData.content; + json1 = JSON.parse(objectDataContent); + json2 = { name: "1234567890".repeat(6554) }; + json3 = { name: 'Test' }; + break; + + case 'item': + objectData = await API.createItem('book', { title: 'Title' }, true, 'data'); + objectDataContent = objectData.content; + json1 = JSON.parse(objectDataContent); + json2 = await API.getItemTemplate('book'); + json3 = { ...json2 }; + json2.title = "1234567890".repeat(6554); + break; + + case 'search': + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'value' + } + ]; + objectData = await API.createSearch('Name', conditions, true, 'data'); + objectDataContent = objectData.content; + json1 = JSON.parse(objectDataContent); + json2 = { + name: "1234567890".repeat(6554), + conditions + }; + json3 = { + name: 'Test', + conditions + }; + break; + } + + let response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ [objectTypePlural]: [json1, json2, json3] }), + { 'Content-Type': 'application/json' } + ); + + Helpers.assertStatusCode(response, 200); + let json = API.getJSONFromResponse(response); + + Helpers.assertStatusForObject(response, 'unchanged', 0); + Helpers.assert413ForObject(response, { index: 1 }); + Helpers.assert200ForObject(response, { index: 2 }); + + + response = await API.userGet(config.userID, + `${objectTypePlural}?format=keys&key=${config.apiKey}`); + Helpers.assertStatusCode(response, 200); + let keys = response.data.trim().split('\n'); + assert.lengthOf(keys, 2); + + for (const [_, value] of Object.entries(json.success)) { + assert.include(keys, value); + } + }; + + const _testMultiObjectWriteInvalidObject = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify([{}]), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 400); + assert.equal(response.data, "Uploaded data must be a JSON object"); + + response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ + [objectTypePlural]: { + foo: "bar" + } + }), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 400); + assert.equal(response.data, `'${objectTypePlural}' must be an array`); + }; + + it('testMultiObjectGet', async function () { + await _testMultiObjectGet('collection'); + await _testMultiObjectGet('item'); + await _testMultiObjectGet('search'); + }); + it('testSingleObjectDelete', async function () { + await _testSingleObjectDelete('collection'); + await _testSingleObjectDelete('item'); + await _testSingleObjectDelete('search'); + }); + it('testMultiObjectDelete', async function () { + await _testMultiObjectDelete('collection'); + await _testMultiObjectDelete('item'); + await _testMultiObjectDelete('search'); + }); + it('testPartialWriteFailure', async function () { + _testPartialWriteFailure('collection'); + _testPartialWriteFailure('item'); + _testPartialWriteFailure('search'); + }); + it('testPartialWriteFailureWithUnchanged', async function () { + await _testPartialWriteFailureWithUnchanged('collection'); + await _testPartialWriteFailureWithUnchanged('item'); + await _testPartialWriteFailureWithUnchanged('search'); + }); + + it('testMultiObjectWriteInvalidObject', async function () { + await _testMultiObjectWriteInvalidObject('collection'); + await _testMultiObjectWriteInvalidObject('item'); + await _testMultiObjectWriteInvalidObject('search'); + }); + + it('testDeleted', async function () { + await API.userClear(config.userID); + + // Create objects + const objectKeys = {}; + objectKeys.tag = ["foo", "bar"]; + + objectKeys.collection = []; + objectKeys.collection.push(await API.createCollection("Name", false, true, 'key')); + objectKeys.collection.push(await API.createCollection("Name", false, true, 'key')); + objectKeys.collection.push(await API.createCollection("Name", false, true, 'key')); + + objectKeys.item = []; + objectKeys.item.push(await API.createItem("book", { title: "Title", tags: objectKeys.tag.map(tag => ({ tag })) }, true, 'key')); + objectKeys.item.push(await API.createItem("book", { title: "Title" }, true, 'key')); + objectKeys.item.push(await API.createItem("book", { title: "Title" }, true, 'key')); + + objectKeys.search = []; + objectKeys.search.push(await API.createSearch("Name", 'default', true, 'key')); + objectKeys.search.push(await API.createSearch("Name", 'default', true, 'key')); + objectKeys.search.push(await API.createSearch("Name", 'default', true, 'key')); + + // Get library version + let response = await API.userGet(config.userID, "items?key=" + config.apiKey + "&format=keys&limit=1"); + let libraryVersion1 = response.headers["last-modified-version"][0]; + + const testDelete = async (objectType, libraryVersion, url) => { + const objectTypePlural = await API.getPluralObjectType(objectType); + const response = await API.userDelete(config.userID, + `${objectTypePlural}?key=${config.apiKey}${url}`, + { "If-Unmodified-Since-Version": libraryVersion }); + Helpers.assertStatusCode(response, 204); + return response.headers["last-modified-version"][0]; + }; + + // Delete first object + let tempLibraryVersion = await testDelete('collection', libraryVersion1, "&collectionKey=" + objectKeys.collection[0]); + tempLibraryVersion = await testDelete('item', tempLibraryVersion, "&itemKey=" + objectKeys.item[0]); + tempLibraryVersion = await testDelete('search', tempLibraryVersion, "&searchKey=" + objectKeys.search[0]); + let libraryVersion2 = tempLibraryVersion; + + // Delete second and third objects + tempLibraryVersion = await testDelete('collection', tempLibraryVersion, "&collectionKey=" + objectKeys.collection.slice(1).join(',')); + tempLibraryVersion = await testDelete('item', tempLibraryVersion, "&itemKey=" + objectKeys.item.slice(1).join(',')); + let libraryVersion3 = await testDelete('search', tempLibraryVersion, "&searchKey=" + objectKeys.search.slice(1).join(',')); + + // Request all deleted objects + response = await API.userGet(config.userID, "deleted?key=" + config.apiKey + "&newer=" + libraryVersion1); + Helpers.assertStatusCode(response, 200); + let json = JSON.parse(response.data); + let version = response.headers["last-modified-version"][0]; + assert.isNotNull(version); + assert.equal(response.headers["content-type"][0], "application/json"); + + // Verify keys + const verifyKeys = async (json, objectType, objectKeys) => { + const objectTypePlural = await API.getPluralObjectType(objectType); + assert.containsAllKeys(json, [objectTypePlural]); + assert.lengthOf(json[objectTypePlural], objectKeys.length); + for (let key of objectKeys) { + assert.include(json[objectTypePlural], key); + } + }; + await verifyKeys(json, 'collection', objectKeys.collection); + await verifyKeys(json, 'item', objectKeys.item); + await verifyKeys(json, 'search', objectKeys.search); + // Tags aren't deleted by removing from items + await verifyKeys(json, 'tag', []); + + // Request second and third deleted objects + response = await API.userGet( + config.userID, + `deleted?key=${config.apiKey}&newer=${libraryVersion2}` + ); + Helpers.assertStatusCode(response, 200); + json = JSON.parse(response.data); + version = response.headers["last-modified-version"][0]; + assert.isNotNull(version); + assert.equal(response.headers["content-type"][0], "application/json"); + + await verifyKeys(json, 'collection', objectKeys.collection.slice(1)); + await verifyKeys(json, 'item', objectKeys.item.slice(1)); + await verifyKeys(json, 'search', objectKeys.search.slice(1)); + // Tags aren't deleted by removing from items + await verifyKeys(json, 'tag', []); + + // Explicit tag deletion + response = await API.userDelete( + config.userID, + `tags?key=${config.apiKey}&tag=${objectKeys.tag.join('%20||%20')}`, + { "If-Unmodified-Since-Version": libraryVersion3 } + ); + Helpers.assertStatusCode(response, 204); + + // Verify deleted tags + response = await API.userGet( + config.userID, + `deleted?key=${config.apiKey}&newer=${libraryVersion3}` + ); + Helpers.assertStatusCode(response, 200); + json = JSON.parse(response.data); + await verifyKeys(json, 'tag', objectKeys.tag); + }); +}); diff --git a/tests/remote_js/test/2/paramTest.js b/tests/remote_js/test/2/paramTest.js new file mode 100644 index 00000000..062c1415 --- /dev/null +++ b/tests/remote_js/test/2/paramTest.js @@ -0,0 +1,314 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('ParametersTests', function () { + this.timeout(config.timeout * 2); + let collectionKeys = []; + let itemKeys = []; + let searchKeys = []; + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + const _testFormatKeys = async (objectType, sorted = false) => { + const objectTypePlural = API.getPluralObjectType(objectType); + const response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&format=keys${sorted ? '&order=title' : ''}`, + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 200); + + const keys = response.data.trim().split('\n'); + keys.sort(); + + switch (objectType) { + case "item": + assert.equal(keys.length, itemKeys.length); + if (sorted) { + assert.deepEqual(keys, itemKeys); + } + else { + keys.forEach((key) => { + assert.include(itemKeys, key); + }); + } + break; + case "collection": + assert.equal(keys.length, collectionKeys.length); + if (sorted) { + assert.deepEqual(keys, collectionKeys); + } + else { + keys.forEach((key) => { + assert.include(collectionKeys, key); + }); + } + break; + case "search": + assert.equal(keys.length, searchKeys.length); + + if (sorted) { + assert.deepEqual(keys, searchKeys); + } + else { + keys.forEach((key) => { + assert.include(searchKeys, key); + }); + } + break; + default: + throw new Error("Unknown object type"); + } + }; + + const _testObjectKeyParameter = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + const xmlArray = []; + let response; + switch (objectType) { + case 'collection': + xmlArray.push(await API.createCollection("Name", false, true)); + xmlArray.push(await API.createCollection("Name", false, true)); + break; + + case 'item': + xmlArray.push(await API.createItem("book", false, true)); + xmlArray.push(await API.createItem("book", false, true)); + break; + + case 'search': + xmlArray.push(await API.createSearch( + "Name", + [{ + condition: "title", + operator: "contains", + value: "test" + }], + true + )); + xmlArray.push(await API.createSearch( + "Name", + [{ + condition: "title", + operator: "contains", + value: "test" + }], + true + )); + break; + } + + const keys = []; + xmlArray.forEach((xml) => { + const data = API.parseDataFromAtomEntry(xml); + keys.push(data.key); + }); + + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&content=json&${objectType}Key=${keys[0]}` + ); + + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + + let xml = API.getXMLFromResponse(response); + const data = API.parseDataFromAtomEntry(xml); + assert.equal(keys[0], data.key); + + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&content=json&${objectType}Key=${keys[0]},${keys[1]}&order=${objectType}KeyList` + ); + + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 2); + + xml = API.getXMLFromResponse(response); + const xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + const key1 = xpath[0]; + assert.equal(keys[0], key1); + const key2 = xpath[1]; + assert.equal(keys[1], key2); + }; + + it('testFormatKeys', async function () { + await API.userClear(config.userID); + for (let i = 0; i < 5; i++) { + const collectionKey = await API.createCollection('Test', false, null, 'key'); + collectionKeys.push(collectionKey); + } + + for (let i = 0; i < 5; i++) { + const itemKey = await API.createItem('book', false, null, 'key'); + itemKeys.push(itemKey); + } + const attachmentItemKey = await API.createAttachmentItem('imported_file', [], false, null, 'key'); + itemKeys.push(attachmentItemKey); + + for (let i = 0; i < 5; i++) { + const searchKey = await API.createSearch('Test', 'default', null, 'key'); + searchKeys.push(searchKey); + } + + await _testFormatKeys('collection'); + await _testFormatKeys('item'); + await _testFormatKeys('search'); + + + itemKeys.sort(); + collectionKeys.sort(); + searchKeys.sort(); + + await _testFormatKeys('collection', true); + await _testFormatKeys('item', true); + await _testFormatKeys('search', true); + }); + + it('testObjectKeyParameter', async function () { + await _testObjectKeyParameter('collection'); + await _testObjectKeyParameter('item'); + await _testObjectKeyParameter('search'); + }); + it('testCollectionQuickSearch', async function () { + const title1 = 'Test Title'; + const title2 = 'Another Title'; + + const keys = []; + keys.push(await API.createCollection(title1, [], true, 'key')); + keys.push(await API.createCollection(title2, [], true, 'key')); + + // Search by title + let response = await API.userGet( + config.userID, + `collections?key=${config.apiKey}&content=json&q=another` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + const xml = API.getXMLFromResponse(response); + const xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + const key = xpath[0]; + assert.equal(keys[1], key); + + // No results + response = await API.userGet( + config.userID, + `collections?key=${config.apiKey}&content=json&q=nothing` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 0); + }); + + it('testItemQuickSearch', async function () { + const title1 = "Test Title"; + const title2 = "Another Title"; + const year2 = "2013"; + + const keys = []; + keys.push(await API.createItem("book", { + title: title1 + }, true, 'key')); + keys.push(await API.createItem("journalArticle", { + title: title2, + date: "November 25, " + year2 + }, true, 'key')); + + // Search by title + let response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + "&content=json&q=" + encodeURIComponent(title1) + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + + let xml = API.getXMLFromResponse(response); + let xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + let key = xpath[0]; + assert.equal(keys[0], key); + + // TODO: Search by creator + + // Search by year + response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + "&content=json&q=" + year2 + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + xml = API.getXMLFromResponse(response); + key = Helpers.xpathEval(xml, '//atom:entry/zapi:key'); + assert.equal(keys[1], key); + + // Search by year + 1 + response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + "&content=json&q=" + (year2 + 1) + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 0); + }); + + it('testItemQuickSearchOrderByDate', async function () { + await API.userClear(config.userID); + const title1 = 'Test Title'; + const title2 = 'Another Title'; + let response, xpath, xml; + const keys = []; + keys.push(await API.createItem('book', { + title: title1, + date: 'February 12, 2013' + }, true, 'key')); + keys.push(await API.createItem('journalArticle', { + title: title2, + date: 'November 25, 2012' + }, true, 'key')); + + // Search for one by title + response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&content=json&q=${encodeURIComponent(title1)}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + + xml = API.getXMLFromResponse(response); + xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + assert.equal(keys[0], xpath[0]); + + // Search by both by title, date asc + response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&content=json&q=title&order=date&sort=asc` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 2); + xml = API.getXMLFromResponse(response); + xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + + assert.equal(keys[1], xpath[0]); + assert.equal(keys[0], xpath[1]); + + // Search by both by title, date desc + response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&content=json&q=title&order=date&sort=desc` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 2); + xml = API.getXMLFromResponse(response); + xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + + assert.equal(keys[0], xpath[0]); + assert.equal(keys[1], xpath[1]); + }); +}); diff --git a/tests/remote_js/test/2/permissionsTest.js b/tests/remote_js/test/2/permissionsTest.js new file mode 100644 index 00000000..e87e2f2a --- /dev/null +++ b/tests/remote_js/test/2/permissionsTest.js @@ -0,0 +1,248 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After, resetGroups } = require("../shared.js"); + +describe('PermissionsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + await resetGroups(); + }); + + after(async function () { + await API2After(); + }); + + it('testUserGroupsAnonymous', async function () { + const response = await API.get(`users/${config.userID}/groups?content=json`); + Helpers.assertStatusCode(response, 200); + + const xml = API.getXMLFromResponse(response); + const groupIDs = Helpers.xpathEval(xml, '//atom:entry/zapi:groupID', false, true); + assert.include(groupIDs, String(config.ownedPublicGroupID), `Owned public group ID ${config.ownedPublicGroupID} not found`); + assert.include(groupIDs, String(config.ownedPublicNoAnonymousGroupID), `Owned public no-anonymous group ID ${config.ownedPublicNoAnonymousGroupID} not found`); + Helpers.assertTotalResults(response, config.numPublicGroups); + }); + + /** + * A key without note access shouldn't be able to create a note. + * Disabled + */ + it('testKeyNoteAccessWriteError', async function() { + this.skip(); + }); + + it('testUserGroupsOwned', async function () { + const response = await API.get( + "users/" + config.userID + "/groups?content=json" + + "&key=" + config.apiKey + ); + Helpers.assertStatusCode(response, 200); + + Helpers.assertTotalResults(response, config.numOwnedGroups); + Helpers.assertNumResults(response, config.numOwnedGroups); + }); + + it('testTagDeletePermissions', async function () { + await API.userClear(config.userID); + + await API.createItem('book', { + tags: [{ tag: 'A' }] + }, true); + + const libraryVersion = await API.getLibraryVersion(); + + await API.setKeyOption( + config.userID, config.apiKey, 'libraryWrite', 0 + ); + + let response = await API.userDelete( + config.userID, + `tags?tag=A&key=${config.apiKey}`, + ); + Helpers.assertStatusCode(response, 403); + + await API.setKeyOption( + config.userID, config.apiKey, 'libraryWrite', 1 + ); + + response = await API.userDelete( + config.userID, + `tags?tag=A&key=${config.apiKey}`, + { 'If-Unmodified-Since-Version': libraryVersion } + ); + Helpers.assertStatusCode(response, 204); + }); + + it("testKeyNoteAccess", async function () { + await API.userClear(config.userID); + + await API.setKeyOption( + config.userID, config.apiKey, 'libraryNotes', 1 + ); + + let keys = []; + let topLevelKeys = []; + let bookKeys = []; + + const makeNoteItem = async (text) => { + const xml = await API.createNoteItem(text, false, true); + const data = API.parseDataFromAtomEntry(xml); + keys.push(data.key); + topLevelKeys.push(data.key); + }; + + const makeBookItem = async (title) => { + let xml = await API.createItem('book', { title: title }, true); + let data = API.parseDataFromAtomEntry(xml); + keys.push(data.key); + topLevelKeys.push(data.key); + bookKeys.push(data.key); + return data.key; + }; + + await makeBookItem("A"); + + await makeNoteItem("

B

"); + await makeNoteItem("

C

"); + await makeNoteItem("

D

"); + await makeNoteItem("

E

"); + + const lastKey = await makeBookItem("F"); + + let xml = await API.createNoteItem("

G

", lastKey, true); + let data = API.parseDataFromAtomEntry(xml); + keys.push(data.key); + + // Create collection and add items to it + let response = await API.userPost( + config.userID, + "collections?key=" + config.apiKey, + JSON.stringify({ + collections: [ + { + name: "Test", + parentCollection: false + } + ] + }), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + let collectionKey = API.getFirstSuccessKeyFromResponse(response); + + response = await API.userPost( + config.userID, + `collections/${collectionKey}/items?key=` + config.apiKey, + topLevelKeys.join(" ") + ); + Helpers.assertStatusCode(response, 204); + + // + // format=atom + // + // Root + response = await API.userGet( + config.userID, "items?key=" + config.apiKey + ); + Helpers.assertNumResults(response, keys.length); + Helpers.assertTotalResults(response, keys.length); + + // Top + response = await API.userGet( + config.userID, "items/top?key=" + config.apiKey + ); + Helpers.assertNumResults(response, topLevelKeys.length); + Helpers.assertTotalResults(response, topLevelKeys.length); + + // Collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items/top?key=" + config.apiKey + ); + Helpers.assertNumResults(response, topLevelKeys.length); + Helpers.assertTotalResults(response, topLevelKeys.length); + + // + // format=keys + // + // Root + response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + "&format=keys" + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.data.trim().split("\n").length, keys.length); + + // Top + response = await API.userGet( + config.userID, + "items/top?key=" + config.apiKey + "&format=keys" + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.data.trim().split("\n").length, topLevelKeys.length); + + // Collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items/top?key=" + config.apiKey + "&format=keys" + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.data.trim().split("\n").length, topLevelKeys.length); + + // Remove notes privilege from key + await API.setKeyOption( + config.userID, config.apiKey, 'libraryNotes', 0 + ); + // + // format=atom + // + // totalResults with limit + response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + "&limit=1" + ); + Helpers.assertNumResults(response, 1); + Helpers.assertTotalResults(response, bookKeys.length); + + // And without limit + response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // Top + response = await API.userGet( + config.userID, + "items/top?key=" + config.apiKey + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // Collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items?key=" + config.apiKey + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // + // format=keys + // + response = await API.userGet( + config.userID, + "items?key=" + config.apiKey + "&format=keys" + ); + keys = response.data.trim().split("\n"); + keys.sort(); + bookKeys.sort(); + assert.deepEqual(bookKeys, keys); + }); +}); diff --git a/tests/remote_js/test/2/relationsTest.js b/tests/remote_js/test/2/relationsTest.js new file mode 100644 index 00000000..1e1444f7 --- /dev/null +++ b/tests/remote_js/test/2/relationsTest.js @@ -0,0 +1,284 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('RelationsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + it('testNewItemRelations', async function () { + const relations = { + "owl:sameAs": "http://zotero.org/groups/1/items/AAAAAAAA", + "dc:relation": [ + "http://zotero.org/users/" + config.userID + "/items/AAAAAAAA", + "http://zotero.org/users/" + config.userID + "/items/BBBBBBBB", + ] + }; + const xml = await API.createItem("book", { relations }, true); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + assert.equal(Object.keys(relations).length, Object.keys(json.relations).length); + + for (const [predicate, object] of Object.entries(relations)) { + if (typeof object === "string") { + assert.equal(object, json.relations[predicate]); + } + else { + for (const rel of object) { + assert.include(json.relations[predicate], rel); + } + } + } + }); + + it('testRelatedItemRelations', async function () { + const relations = { + "owl:sameAs": "http://zotero.org/groups/1/items/AAAAAAAA" + }; + + const item1JSON = await API.createItem("book", { relations: relations }, true, 'json'); + const item2JSON = await API.createItem("book", null, this, 'json'); + + const uriPrefix = "http://zotero.org/users/" + config.userID + "/items/"; + const item1URI = uriPrefix + item1JSON.itemKey; + const item2URI = uriPrefix + item2JSON.itemKey; + + // Add item 2 as related item of item 1 + relations["dc:relation"] = item2URI; + item1JSON.relations = relations; + const response = await API.userPut( + config.userID, + "items/" + item1JSON.itemKey + "?key=" + config.apiKey, + JSON.stringify(item1JSON) + ); + Helpers.assertStatusCode(response, 204); + + // Make sure it exists on item 1 + const xml = await API.getItemXML(item1JSON.itemKey); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + assert.equal(Object.keys(relations).length, Object.keys(json.relations).length); + for (const [predicate, object] of Object.entries(relations)) { + assert.equal(object, json.relations[predicate]); + } + + // And item 2, since related items are bidirectional + const xml2 = await API.getItemXML(item2JSON.itemKey); + const data2 = API.parseDataFromAtomEntry(xml2); + const item2JSON2 = JSON.parse(data2.content); + assert.equal(1, Object.keys(item2JSON2.relations).length); + assert.equal(item1URI, item2JSON2.relations["dc:relation"]); + + // Sending item 2's unmodified JSON back up shouldn't cause the item to be updated. + // Even though we're sending a relation that's technically not part of the item, + // when it loads the item it will load the reverse relations too and therefore not + // add a relation that it thinks already exists. + const response2 = await API.userPut( + config.userID, + "items/" + item2JSON.itemKey + "?key=" + config.apiKey, + JSON.stringify(item2JSON2) + ); + Helpers.assertStatusCode(response2, 204); + assert.equal(parseInt(item2JSON2.itemVersion), response2.headers["last-modified-version"][0]); + }); + + // Same as above, but in a single request + it('testRelatedItemRelationsSingleRequest', async function () { + const uriPrefix = "http://zotero.org/users/" + config.userID + "/items/"; + const item1Key = Helpers.uniqueID(); + const item2Key = Helpers.uniqueID(); + const item1URI = uriPrefix + item1Key; + const item2URI = uriPrefix + item2Key; + + const item1JSON = await API.getItemTemplate('book'); + item1JSON.itemKey = item1Key; + item1JSON.itemVersion = 0; + item1JSON.relations['dc:relation'] = item2URI; + const item2JSON = await API.getItemTemplate('book'); + item2JSON.itemKey = item2Key; + item2JSON.itemVersion = 0; + + const response = await API.postItems([item1JSON, item2JSON]); + Helpers.assertStatusCode(response, 200); + + // Make sure it exists on item 1 + const xml = await API.getItemXML(item1JSON.itemKey); + const data = API.parseDataFromAtomEntry(xml); + const parsedJson = JSON.parse(data.content); + + assert.lengthOf(Object.keys(parsedJson.relations), 1); + assert.equal(parsedJson.relations['dc:relation'], item2URI); + + // And item 2, since related items are bidirectional + const xml2 = await API.getItemXML(item2JSON.itemKey); + const data2 = API.parseDataFromAtomEntry(xml2); + const parsedJson2 = JSON.parse(data2.content); + assert.lengthOf(Object.keys(parsedJson2.relations), 1); + assert.equal(parsedJson2.relations['dc:relation'], item1URI); + }); + + it('testInvalidItemRelation', async function () { + let response = await API.createItem('book', { + relations: { + 'foo:unknown': 'http://zotero.org/groups/1/items/AAAAAAAA' + } + }, true, 'response'); + + Helpers.assert400ForObject(response, { message: "Unsupported predicate 'foo:unknown'" }); + + response = await API.createItem('book', { + relations: { + 'owl:sameAs': 'Not a URI' + } + }, this, 'response'); + + Helpers.assert400ForObject(response, { message: "'relations' values currently must be Zotero item URIs" }); + + response = await API.createItem('book', { + relations: { + 'owl:sameAs': ['Not a URI'] + } + }, this, 'response'); + + Helpers.assert400ForObject(response, { message: "'relations' values currently must be Zotero item URIs" }); + }); + + it('testDeleteItemRelation', async function () { + const relations = { + "owl:sameAs": [ + "http://zotero.org/groups/1/items/AAAAAAAA", + "http://zotero.org/groups/1/items/BBBBBBBB" + ], + "dc:relation": "http://zotero.org/users/" + config.userID + + "/items/AAAAAAAA" + }; + + const data = await API.createItem("book", { + relations: relations + }, true, 'data'); + + let json = JSON.parse(data.content); + + // Remove a relation + json.relations['owl:sameAs'] = relations['owl:sameAs'] = relations['owl:sameAs'][0]; + const response = await API.userPut( + config.userID, + "items/" + data.key + "?key=" + config.apiKey, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + // Make sure it's gone + const xml = await API.getItemXML(data.key); + const itemData = API.parseDataFromAtomEntry(xml); + json = JSON.parse(itemData.content); + assert.equal(Object.keys(relations).length, Object.keys(json.relations).length); + for (const [predicate, object] of Object.entries(relations)) { + assert.deepEqual(object, json.relations[predicate]); + } + + // Delete all + json.relations = {}; + const deleteResponse = await API.userPut( + config.userID, + "items/" + data.key + "?key=" + config.apiKey, + JSON.stringify(json) + ); + Helpers.assertStatusCode(deleteResponse, 204); + + // Make sure they're gone + const xmlAfterDelete = await API.getItemXML(data.key); + const itemDataAfterDelete = API.parseDataFromAtomEntry(xmlAfterDelete); + const responseDataAfterDelete = JSON.parse(itemDataAfterDelete.content); + assert.lengthOf(Object.keys(responseDataAfterDelete.relations), 0); + }); + + // + // Collections + // + it('testNewCollectionRelations', async function () { + const relationsObj = { + "owl:sameAs": "http://zotero.org/groups/1/collections/AAAAAAAA" + }; + const data = await API.createCollection("Test", + { relations: relationsObj }, true, 'data'); + const json = JSON.parse(data.content); + assert.equal(Object.keys(json.relations).length, Object.keys(relationsObj).length); + for (const [predicate, object] of Object.entries(relationsObj)) { + assert.equal(object, json.relations[predicate]); + } + }); + + it('testInvalidCollectionRelation', async function () { + const json = { + name: "Test", + relations: { + "foo:unknown": "http://zotero.org/groups/1/collections/AAAAAAAA" + } + }; + const response = await API.userPost( + config.userID, + "collections?key=" + config.apiKey, + JSON.stringify({ collections: [json] }) + ); + + Helpers.assert400ForObject(response, { message: "Unsupported predicate 'foo:unknown'" }); + + json.relations = { + "owl:sameAs": "Not a URI" + }; + const response2 = await API.userPost( + config.userID, + "collections?key=" + config.apiKey, + JSON.stringify({ collections: [json] }) + ); + Helpers.assert400ForObject(response2, { message: "'relations' values currently must be Zotero collection URIs" }); + + json.relations = ["http://zotero.org/groups/1/collections/AAAAAAAA"]; + const response3 = await API.userPost( + config.userID, + "collections?key=" + config.apiKey, + JSON.stringify({ collections: [json] }) + ); + Helpers.assert400ForObject(response3, { message: "'relations' property must be an object" }); + }); + + it('testDeleteCollectionRelation', async function () { + const relations = { + "owl:sameAs": "http://zotero.org/groups/1/collections/AAAAAAAA" + }; + const data = await API.createCollection("Test", { + relations: relations + }, true, 'data'); + const json = JSON.parse(data.content); + + // Remove all relations + json.relations = {}; + delete relations['owl:sameAs']; + const response = await API.userPut( + config.userID, + `collections/${data.key}?key=${config.apiKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + // Make sure it's gone + const xml = await API.getCollectionXML(data.key); + const parsedData = API.parseDataFromAtomEntry(xml); + const jsonData = JSON.parse(parsedData.content); + assert.equal(Object.keys(jsonData.relations).length, Object.keys(relations).length); + for (const key in relations) { + assert.equal(jsonData.relations[key], relations[key]); + } + }); +}); diff --git a/tests/remote_js/test/2/searchTest.js b/tests/remote_js/test/2/searchTest.js new file mode 100644 index 00000000..fa623489 --- /dev/null +++ b/tests/remote_js/test/2/searchTest.js @@ -0,0 +1,171 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('SearchTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + const testNewSearch = async () => { + let name = "Test Search"; + let conditions = [ + { + condition: "title", + operator: "contains", + value: "test" + }, + { + condition: "noChildren", + operator: "false", + value: "" + }, + { + condition: "fulltextContent/regexp", + operator: "contains", + value: "/test/" + } + ]; + + let xml = await API.createSearch(name, conditions, true); + assert.equal(parseInt(Helpers.xpathEval(xml, '/atom:feed/zapi:totalResults')), 1); + + let data = API.parseDataFromAtomEntry(xml); + let json = JSON.parse(data.content); + assert.equal(name, json.name); + assert.isArray(json.conditions); + assert.equal(conditions.length, json.conditions.length); + for (let i = 0; i < conditions.length; i++) { + for (let key in conditions[i]) { + assert.equal(conditions[i][key], json.conditions[i][key]); + } + } + + return data; + }; + + it('testModifySearch', async function () { + const newSearchData = await testNewSearch(); + let json = JSON.parse(newSearchData.content); + + // Remove one search condition + json.conditions.shift(); + + const name = json.name; + const conditions = json.conditions; + + let response = await API.userPut( + config.userID, + `searches/${newSearchData.key}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": newSearchData.version + } + ); + + Helpers.assertStatusCode(response, 204); + + const xml = await API.getSearchXML(newSearchData.key); + const data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + assert.equal(name, json.name); + assert.isArray(json.conditions); + assert.equal(conditions.length, json.conditions.length); + + for (let i = 0; i < conditions.length; i++) { + const condition = conditions[i]; + assert.equal(condition.field, json.conditions[i].field); + assert.equal(condition.operator, json.conditions[i].operator); + assert.equal(condition.value, json.conditions[i].value); + } + }); + + it('testNewSearchNoName', async function () { + const conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'test', + }, + ]; + const headers = { + 'Content-Type': 'application/json', + }; + const response = await API.createSearch('', conditions, headers, 'responsejson'); + Helpers.assert400ForObject(response, { message: 'Search name cannot be empty' }); + }); + + it('testNewSearchNoConditions', async function () { + const json = await API.createSearch("Test", [], true, 'responsejson'); + Helpers.assert400ForObject(json, { message: "'conditions' cannot be empty" }); + }); + + it('testNewSearchConditionErrors', async function () { + let json = await API.createSearch( + 'Test', + [ + { + operator: 'contains', + value: 'test' + } + ], + true, + 'responsejson' + ); + Helpers.assert400ForObject(json, { message: "'condition' property not provided for search condition" }); + + + json = await API.createSearch( + 'Test', + [ + { + condition: '', + operator: 'contains', + value: 'test' + } + ], + true, + 'responsejson' + ); + Helpers.assert400ForObject(json, { message: 'Search condition cannot be empty' }); + + + json = await API.createSearch( + 'Test', + [ + { + condition: 'title', + value: 'test' + } + ], + true, + 'responsejson' + ); + Helpers.assert400ForObject(json, { message: "'operator' property not provided for search condition" }); + + + json = await API.createSearch( + 'Test', + [ + { + condition: 'title', + operator: '', + value: 'test' + } + ], + true, + 'responsejson' + ); + Helpers.assert400ForObject(json, { message: 'Search operator cannot be empty' }); + }); +}); diff --git a/tests/remote_js/test/2/settingsTest.js b/tests/remote_js/test/2/settingsTest.js new file mode 100644 index 00000000..d7d0bd05 --- /dev/null +++ b/tests/remote_js/test/2/settingsTest.js @@ -0,0 +1,433 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After, resetGroups } = require("../shared.js"); + +describe('SettingsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + await resetGroups(); + }); + + after(async function () { + await API2After(); + await resetGroups(); + }); + + beforeEach(async function () { + await API.userClear(config.userID); + await API.groupClear(config.ownedPrivateGroupID); + }); + + it('testAddUserSetting', async function () { + const settingKey = "tagColors"; + const value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + const libraryVersion = await API.getLibraryVersion(); + + const json = { + value: value + }; + + // No version + let response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 428); + + // Version must be 0 for non-existent setting + response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "1" + } + ); + Helpers.assertStatusCode(response, 412); + + // Create + response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "0" + } + ); + Helpers.assertStatusCode(response, 204); + + // Multi-object GET + response = await API.userGet( + config.userID, + `settings?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.headers['content-type'][0], "application/json"); + let jsonResponse = JSON.parse(response.data); + + assert.property(jsonResponse, settingKey); + assert.deepEqual(value, jsonResponse[settingKey].value); + assert.equal(parseInt(libraryVersion) + 1, jsonResponse[settingKey].version); + + // Single-object GET + response = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.headers['content-type'][0], "application/json"); + jsonResponse = JSON.parse(response.data); + + assert.deepEqual(value, jsonResponse.value); + assert.equal(parseInt(libraryVersion) + 1, jsonResponse.version); + }); + + it('testAddUserSettingMultiple', async function () { + const settingKey = 'tagColors'; + const val = [ + { + name: '_READ', + color: '#990000', + }, + ]; + + // TODO: multiple, once more settings are supported + const libraryVersion = await API.getLibraryVersion(); + + const json = { + [settingKey]: { + value: val + }, + }; + const response = await API.userPost( + config.userID, + `settings?key=${config.apiKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 204); + + // Multi-object GET + const multiObjResponse = await API.userGet( + config.userID, + `settings?key=${config.apiKey}` + ); + Helpers.assertStatusCode(multiObjResponse, 200); + + assert.equal(multiObjResponse.headers['content-type'][0], 'application/json'); + const multiObjJson = JSON.parse(multiObjResponse.data); + assert.property(multiObjJson, settingKey); + assert.deepEqual(multiObjJson[settingKey].value, val); + assert.equal(multiObjJson[settingKey].version, parseInt(libraryVersion) + 1); + + // Single-object GET + const singleObjResponse = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + + Helpers.assertStatusCode(singleObjResponse, 200); + assert.equal(singleObjResponse.headers['content-type'][0], 'application/json'); + const singleObjJson = JSON.parse(singleObjResponse.data); + assert.exists(singleObjJson); + assert.deepEqual(singleObjJson.value, val); + assert.equal(singleObjJson.version, parseInt(libraryVersion) + 1); + }); + + it('testAddGroupSettingMultiple', async function () { + const settingKey = "tagColors"; + const value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + // TODO: multiple, once more settings are supported + + const groupID = config.ownedPrivateGroupID; + const libraryVersion = await API.getGroupLibraryVersion(groupID); + + const json = { + [settingKey]: { + value: value + } + }; + + const response = await API.groupPost( + groupID, + `settings?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 204); + + // Multi-object GET + const response2 = await API.groupGet( + groupID, + `settings?key=${config.apiKey}` + ); + + Helpers.assertStatusCode(response2, 200); + assert.equal(response2.headers['content-type'][0], "application/json"); + const json2 = JSON.parse(response2.data); + assert.exists(json2); + assert.property(json2, settingKey); + assert.deepEqual(value, json2[settingKey].value); + assert.equal(parseInt(libraryVersion) + 1, json2[settingKey].version); + + // Single-object GET + const response3 = await API.groupGet( + groupID, + `settings/${settingKey}?key=${config.apiKey}` + ); + + Helpers.assertStatusCode(response3, 200); + assert.equal(response3.headers['content-type'][0], "application/json"); + const json3 = JSON.parse(response3.data); + assert.exists(json3); + assert.deepEqual(value, json3.value); + assert.equal(parseInt(libraryVersion) + 1, json3.version); + }); + + it('testDeleteNonexistentSetting', async function () { + const response = await API.userDelete(config.userID, + `settings/nonexistentSetting?key=${config.apiKey}`, + { "If-Unmodified-Since-Version": "0" }); + Helpers.assertStatusCode(response, 404); + }); + + it('testUnsupportedSetting', async function () { + const settingKey = "unsupportedSetting"; + let value = true; + + const json = { + value: value, + version: 0 + }; + + const response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 400, `Invalid setting '${settingKey}'`); + }); + + it('testUpdateUserSetting', async function () { + let settingKey = "tagColors"; + let value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + let libraryVersion = await API.getLibraryVersion(); + + let json = { + value: value, + version: 0 + }; + + // Create + let response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json.value, value); + assert.equal(parseInt(json.version), parseInt(libraryVersion) + 1); + + // Update with no change + response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json.value, value); + assert.equal(parseInt(json.version), parseInt(libraryVersion) + 1); + + let newValue = [ + { + name: "_READ", + color: "#CC9933" + } + ]; + json.value = newValue; + + // Update, no change + response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json.value, newValue); + assert.equal(parseInt(json.version), parseInt(libraryVersion) + 2); + }); + + + it('testUnsupportedSettingMultiple', async function () { + const settingKey = 'unsupportedSetting'; + const json = { + tagColors: { + value: { + name: '_READ', + color: '#990000' + }, + version: 0 + }, + [settingKey]: { + value: false, + version: 0 + } + }; + + const libraryVersion = await API.getLibraryVersion(); + + let response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 400); + + // Valid setting shouldn't exist, and library version should be unchanged + response = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 404); + assert.equal(libraryVersion, await API.getLibraryVersion()); + }); + + it('testOverlongSetting', async function () { + const settingKey = "tagColors"; + const value = [ + { + name: "abcdefghij".repeat(3001), + color: "#990000" + } + ]; + + const json = { + value: value, + version: 0 + }; + + const response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 400, "'value' cannot be longer than 30000 characters"); + }); + + it('testDeleteUserSetting', async function () { + let settingKey = "tagColors"; + let value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + let json = { + value: value, + version: 0 + }; + + let libraryVersion = parseInt(await API.getLibraryVersion()); + + // Create + let response = await API.userPut( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Delete + response = await API.userDelete( + config.userID, + `settings/${settingKey}?key=${config.apiKey}`, + { + "If-Unmodified-Since-Version": `${libraryVersion + 1}` + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}?key=${config.apiKey}` + ); + Helpers.assert404(response); + + assert.equal(libraryVersion + 2, await API.getLibraryVersion()); + }); +}); diff --git a/tests/remote_js/test/2/sortTest.js b/tests/remote_js/test/2/sortTest.js new file mode 100644 index 00000000..e52816d8 --- /dev/null +++ b/tests/remote_js/test/2/sortTest.js @@ -0,0 +1,131 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('SortTests', function () { + this.timeout(config.timeout); + //let collectionKeys = []; + let itemKeys = []; + let childAttachmentKeys = []; + let childNoteKeys = []; + //let searchKeys = []; + + let titles = ['q', 'c', 'a', 'j', 'e', 'h', 'i']; + let names = ['m', 's', 'a', 'bb', 'ba', '', '']; + let attachmentTitles = ['v', 'x', null, 'a', null]; + let notes = [null, 'aaa', null, null, 'taf']; + + before(async function () { + await API2Before(); + await setup(); + }); + + after(async function () { + await API2After(); + }); + + const setup = async () => { + let titleIndex = 0; + for (let i = 0; i < titles.length - 2; i++) { + const key = await API.createItem("book", { + title: titles[titleIndex], + creators: [ + { + creatorType: "author", + name: names[i] + } + ] + }, true, 'key'); + titleIndex += 1; + // Child attachments + if (attachmentTitles[i]) { + childAttachmentKeys.push(await API.createAttachmentItem( + "imported_file", { + title: attachmentTitles[i] + }, key, true, 'key')); + } + // Child notes + if (notes[i]) { + childNoteKeys.push(await API.createNoteItem(notes[i], key, true, 'key')); + } + + itemKeys.push(key); + } + // Top-level attachment + itemKeys.push(await API.createAttachmentItem("imported_file", { + title: titles[titleIndex] + }, false, null, 'key')); + titleIndex += 1; + // Top-level note + itemKeys.push(await API.createNoteItem(titles[titleIndex], false, null, 'key')); + // + // Collections + // + /*for (let i=0; i<5; i++) { + collectionKeys.push(await API.createCollection("Test", false, true, 'key')); + }*/ + + // + // Searches + // + /*for (let i=0; i<5; i++) { + searchKeys.push(await API.createSearch("Test", 'default', null, 'key')); + }*/ + }; + + it('testSortTopItemsTitle', async function () { + let response = await API.userGet( + config.userID, + "items/top?key=" + config.apiKey + "&format=keys&order=title" + ); + Helpers.assertStatusCode(response, 200); + + let keys = response.data.trim().split("\n"); + + let titlesToIndex = {}; + titles.forEach((v, i) => { + titlesToIndex[v] = i; + }); + let titlesSorted = [...titles]; + titlesSorted.sort(); + let correct = {}; + titlesSorted.forEach((title) => { + // The key at position k in itemKeys should be at the same position in keys + let index = titlesToIndex[title]; + correct[index] = keys[index]; + }); + correct = Object.keys(correct).map(key => correct[key]); + assert.deepEqual(correct, keys); + }); + + it('testSortTopItemsCreator', async function () { + let response = await API.userGet( + config.userID, + "items/top?key=" + config.apiKey + "&format=keys&order=creator" + ); + Helpers.assertStatusCode(response, 200); + let keys = response.data.trim().split("\n"); + let namesCopy = { ...names }; + let sortFunction = function (a, b) { + if (a === '' && b !== '') return 1; + if (b === '' && a !== '') return -1; + if (a < b) return -1; + if (a > b) return 11; + return 0; + }; + let namesEntries = Object.entries(namesCopy); + namesEntries.sort((a, b) => sortFunction(a[1], b[1])); + // The key at position k in itemKeys should be at the same position in keys + assert.equal(Object.keys(namesEntries).length, keys.length); + let correct = {}; + namesEntries.forEach((entry, i) => { + correct[i] = itemKeys[parseInt(entry[0])]; + }); + correct = Object.keys(correct).map(key => correct[key]); + // Check attachment and note, which should fall back to ordered added (itemID) + assert.deepEqual(correct, keys); + }); +}); diff --git a/tests/remote_js/test/2/storageAdmin.js b/tests/remote_js/test/2/storageAdmin.js new file mode 100644 index 00000000..4e50d496 --- /dev/null +++ b/tests/remote_js/test/2/storageAdmin.js @@ -0,0 +1,49 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('StorageAdminTests', function () { + this.timeout(config.timeout); + const DEFAULT_QUOTA = 300; + + before(async function () { + await API2Before(); + await setQuota(0, 0, DEFAULT_QUOTA); + }); + + after(async function () { + await API2After(); + }); + + const setQuota = async (quota, expiration, expectedQuota) => { + let response = await API.post('users/' + config.userID + '/storageadmin', + `quota=${quota}&expiration=${expiration}`, + { "content-type": 'application/x-www-form-urlencoded' }, + { + username: config.rootUsername, + password: config.rootPassword + }); + Helpers.assertStatusCode(response, 200); + let xml = API.getXMLFromResponse(response); + let xmlQuota = xml.getElementsByTagName("quota")[0].innerHTML; + assert.equal(xmlQuota, expectedQuota); + if (expiration != 0) { + const xmlExpiration = xml.getElementsByTagName("expiration")[0].innerHTML; + assert.equal(xmlExpiration, expiration); + } + }; + it('test2GB', async function () { + const quota = 2000; + const expiration = Math.floor(Date.now() / 1000) + (86400 * 365); + await setQuota(quota, expiration, quota); + }); + + it('testUnlimited', async function () { + const quota = 'unlimited'; + const expiration = Math.floor(Date.now() / 1000) + (86400 * 365); + await setQuota(quota, expiration, quota); + }); +}); diff --git a/tests/remote_js/test/2/tagTest.js b/tests/remote_js/test/2/tagTest.js new file mode 100644 index 00000000..b28392ee --- /dev/null +++ b/tests/remote_js/test/2/tagTest.js @@ -0,0 +1,259 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('TagTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API2Before(); + API.useAPIVersion(2); + }); + + after(async function () { + await API2After(); + }); + it('test_empty_tag_should_be_ignored', async function () { + let json = await API.getItemTemplate("book"); + json.tags.push({ tag: "", type: 1 }); + + let response = await API.postItem(json); + Helpers.assertStatusCode(response, 200); + }); + + it('testInvalidTagObject', async function () { + let json = await API.getItemTemplate("book"); + json.tags.push(["invalid"]); + + let headers = { "Content-Type": "application/json" }; + let response = await API.postItem(json, headers); + + Helpers.assert400ForObject(response, { message: "Tag must be an object" }); + }); + + it('testTagSearch', async function () { + const tags1 = ["a", "aa", "b"]; + const tags2 = ["b", "c", "cc"]; + + await API.createItem("book", { + tags: tags1.map((tag) => { + return { tag: tag }; + }) + }, true, 'key'); + + await API.createItem("book", { + tags: tags2.map((tag) => { + return { tag: tag }; + }) + }, true, 'key'); + + let response = await API.userGet( + config.userID, + "tags?key=" + config.apiKey + + "&content=json&tag=" + tags1.join("%20||%20"), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, tags1.length); + }); + + it('testTagNewer', async function () { + await API.userClear(config.userID); + + // Create items with tags + await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }, true); + + const version = await API.getLibraryVersion(); + + // 'newer' shouldn't return any results + let response = await API.userGet( + config.userID, + `tags?key=${config.apiKey}&content=json&newer=${version}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 0); + + // Create another item with tags + await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "c" } + ] + }, true); + + // 'newer' should return new tag + response = await API.userGet( + config.userID, + `tags?key=${config.apiKey}&content=json&newer=${version}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + + assert.isAbove(parseInt(response.headers['last-modified-version']), parseInt(version)); + + const content = await API.getContentFromResponse(response); + const json = JSON.parse(content); + assert.strictEqual(json.tag, 'c'); + assert.strictEqual(json.type, 0); + }); + + it('testMultiTagDelete', async function () { + const tags1 = ["a", "aa", "b"]; + const tags2 = ["b", "c", "cc"]; + const tags3 = ["Foo"]; + + await API.createItem("book", { + tags: tags1.map(tag => ({ tag: tag })) + }, true, 'key'); + + await API.createItem("book", { + tags: tags2.map(tag => ({ tag: tag, type: 1 })) + }, true, 'key'); + + await API.createItem("book", { + tags: tags3.map(tag => ({ tag: tag })) + }, true, 'key'); + + let libraryVersion = await API.getLibraryVersion(); + libraryVersion = parseInt(libraryVersion); + + // Missing version header + let response = await API.userDelete( + config.userID, + `tags?key=${config.apiKey}&content=json&tag=${tags1.concat(tags2).map(tag => encodeURIComponent(tag)).join("%20||%20")}` + ); + Helpers.assertStatusCode(response, 428); + + // Outdated version header + response = await API.userDelete( + config.userID, + `tags?key=${config.apiKey}&content=json&tag=${tags1.concat(tags2).map(tag => encodeURIComponent(tag)).join("%20||%20")}`, + { "If-Unmodified-Since-Version": `${libraryVersion - 1}` } + ); + Helpers.assertStatusCode(response, 412); + + // Delete + response = await API.userDelete( + config.userID, + `tags?key=${config.apiKey}&content=json&tag=${tags1.concat(tags2).map(tag => encodeURIComponent(tag)).join("%20||%20")}`, + { "If-Unmodified-Since-Version": `${libraryVersion}` } + ); + Helpers.assertStatusCode(response, 204); + + // Make sure they're gone + response = await API.userGet( + config.userID, + `tags?key=${config.apiKey}&content=json&tag=${tags1.concat(tags2, tags3).map(tag => encodeURIComponent(tag)).join("%20||%20")}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + }); + + /** + * When modifying a tag on an item, only the item itself should have its + * version updated, not other items that had (and still have) the same tag + */ + it('testTagAddItemVersionChange', async function () { + let data1 = await API.createItem("book", { + tags: [{ + tag: "a" + }, + { + tag: "b" + }] + }, true, 'data'); + let json1 = JSON.parse(data1.content); + + let data2 = await API.createItem("book", { + tags: [{ + tag: "a" + }, + { + tag: "c" + }] + }, true, 'data'); + let json2 = JSON.parse(data2.content); + let version2 = data2.version; + version2 = parseInt(version2); + + // Remove tag 'a' from item 1 + json1.tags = [{ + tag: "d" + }, + { + tag: "c" + }]; + + let response = await API.postItem(json1); + Helpers.assertStatusCode(response, 200); + + // Item 1 version should be one greater than last update + let xml1 = await API.getItemXML(json1.itemKey); + data1 = await API.parseDataFromAtomEntry(xml1); + assert.equal(parseInt(data1.version), version2 + 1); + + // Item 2 version shouldn't have changed + let xml2 = await API.getItemXML(json2.itemKey); + data2 = await API.parseDataFromAtomEntry(xml2); + assert.equal(parseInt(data2.version), version2); + }); + + it('testItemTagSearch', async function () { + await API.userClear(config.userID); + + // Create items with tags + let key1 = await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }, true, 'key'); + + let key2 = await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "c" } + ] + }, true, 'key'); + + let checkTags = async function (tagComponent, assertingKeys = []) { + let response = await API.userGet( + config.userID, + `items?key=${config.apiKey}&format=keys&${tagComponent}` + ); + Helpers.assertStatusCode(response, 200); + if (assertingKeys.length != 0) { + let keys = response.data.trim().split("\n"); + + assert.equal(keys.length, assertingKeys.length); + for (let assertingKey of assertingKeys) { + assert.include(keys, assertingKey); + } + } + else { + assert.isEmpty(response.data.trim()); + } + return response; + }; + + // Searches + await checkTags("tag=a", [key2, key1]); + await checkTags("tag=a&tag=c", [key2]); + await checkTags("tag=b&tag=c", []); + await checkTags("tag=b%20||%20c", [key1, key2]); + await checkTags("tag=a%20||%20b%20||%20c", [key1, key2]); + await checkTags("tag=-a"); + await checkTags("tag=-b", [key2]); + await checkTags("tag=b%20||%20c&tag=a", [key1, key2]); + await checkTags("tag=-z", [key1, key2]); + await checkTags("tag=B", [key1]); + }); +}); diff --git a/tests/remote_js/test/2/versionTest.js b/tests/remote_js/test/2/versionTest.js new file mode 100644 index 00000000..6e2f7233 --- /dev/null +++ b/tests/remote_js/test/2/versionTest.js @@ -0,0 +1,606 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api2.js'); +const Helpers = require('../../helpers2.js'); +const { API2Before, API2After } = require("../shared.js"); + +describe('VersionsTests', function () { + this.timeout(config.timeout * 2); + + before(async function () { + await API2Before(); + }); + + after(async function () { + await API2After(); + }); + + const _capitalizeFirstLetter = (string) => { + return string.charAt(0).toUpperCase() + string.slice(1); + }; + + const _modifyJSONObject = async (objectType, json) => { + switch (objectType) { + case "collection": + json.name = "New Name " + Helpers.uniqueID(); + return json; + case "item": + json.title = "New Title " + Helpers.uniqueID(); + return json; + case "search": + json.name = "New Name " + Helpers.uniqueID(); + return json; + default: + throw new Error("Unknown object type"); + } + }; + + const _testSingleObjectLastModifiedVersion = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + const versionProp = objectType + 'Version'; + let objectKey; + switch (objectType) { + case 'collection': + objectKey = await API.createCollection('Name', false, true, 'key'); + break; + case 'item': + objectKey = await API.createItem( + 'book', + { title: 'Title' }, + true, + 'key' + ); + break; + case 'search': + objectKey = await API.createSearch( + 'Name', + [ + { + condition: 'title', + operator: 'contains', + value: 'test' + } + ], + this, + 'key' + ); + break; + } + + // Make sure all three instances of the object version + // (Last-Modified-Version, zapi:version, and the JSON + // {$objectType}Version property match the library version + let response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}&content=json` + ); + + Helpers.assertStatusCode(response, 200); + const objectVersion = response.headers['last-modified-version'][0]; + const xml = API.getXMLFromResponse(response); + const data = API.parseDataFromAtomEntry(xml); + const json = JSON.parse(data.content); + assert.equal(objectVersion, json[versionProp]); + assert.equal(objectVersion, data.version); + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&limit=1` + ); + Helpers.assertStatusCode(response, 200); + const libraryVersion = response.headers['last-modified-version'][0]; + assert.equal(libraryVersion, objectVersion); + + _modifyJSONObject(objectType, json); + + // No If-Unmodified-Since-Version or JSON version property + delete json[versionProp]; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 428); + + // Out of date version + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': objectVersion - 1 + } + ); + Helpers.assertStatusCode(response, 412); + + // Update with version header + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + JSON.stringify(json), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': objectVersion + } + ); + Helpers.assertStatusCode(response, 204); + const newObjectVersion = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(newObjectVersion), parseInt(objectVersion)); + + // Update object with JSON version property + _modifyJSONObject(objectType, json); + json[versionProp] = newObjectVersion; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 204); + const newObjectVersion2 = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(newObjectVersion2), parseInt(newObjectVersion)); + + // Make sure new library version matches new object version + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&limit=1` + ); + Helpers.assertStatusCode(response, 200); + const newLibraryVersion = response.headers['last-modified-version'][0]; + assert.equal(parseInt(newObjectVersion2), parseInt(newLibraryVersion)); + + // Create an item to increase the library version, and make sure + // original object version stays the same + await API.createItem('book', { title: 'Title' }, this, 'key'); + response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}&limit=1` + ); + Helpers.assertStatusCode(response, 200); + const newObjectVersion3 = response.headers['last-modified-version'][0]; + assert.equal(parseInt(newLibraryVersion), parseInt(newObjectVersion3)); + + // + // Delete object + // + // No If-Unmodified-Since-Version + response = await API.userDelete( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}` + ); + Helpers.assertStatusCode(response, 428); + + // Outdated If-Unmodified-Since-Version + response = await API.userDelete( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + { 'If-Unmodified-Since-Version': objectVersion } + ); + Helpers.assertStatusCode(response, 412); + + // Delete object + response = await API.userDelete( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}`, + { 'If-Unmodified-Since-Version': newObjectVersion2 } + ); + Helpers.assertStatusCode(response, 204); + }; + + const _testMultiObjectLastModifiedVersion = async (objectType) => { + await API.userClear(config.userID); + const objectTypePlural = API.getPluralObjectType(objectType); + const objectKeyProp = objectType + "Key"; + const objectVersionProp = objectType + "Version"; + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&limit=1` + ); + + let version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + + let json; + switch (objectType) { + case 'collection': + json = {}; + json.name = "Name"; + break; + + case 'item': + json = await API.getItemTemplate("book"); + json.creators[0].firstName = "Test"; + json.creators[0].lastName = "Test"; + break; + + case 'search': + json = {}; + json.name = "Name"; + json.conditions = []; + json.conditions.push({ + condition: "title", + operator: "contains", + value: "test" + }); + break; + } + + // Outdated library version + const headers1 = { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version - 1 + }; + response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ + [objectTypePlural]: [json] + }), + headers1 + ); + + Helpers.assertStatusCode(response, 412); + + // Make sure version didn't change during failure + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&limit=1` + ); + + assert.equal(version, parseInt(response.headers['last-modified-version'][0])); + + // Create a new object, using library timestamp + const headers2 = { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + }; + response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ + [objectTypePlural]: [json] + }), + headers2 + ); + Helpers.assertStatusCode(response, 200); + Helpers.assert200ForObject(response); + const version2 = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version2); + // Version should be incremented on new object + assert.isAbove(version2, version); + + const objectKey = API.getFirstSuccessKeyFromResponse(response); + + // Check single-object request + response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}&content=json` + ); + Helpers.assertStatusCode(response, 200); + + version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + assert.equal(version2, version); + + json[objectKeyProp] = objectKey; + // Modify object + switch (objectType) { + case 'collection': + json.name = "New Name"; + break; + + case 'item': + json.title = "New Title"; + break; + + case 'search': + json.name = "New Name"; + break; + } + + delete json[objectVersionProp]; + + // No If-Unmodified-Since-Version or object version property + response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ + [objectTypePlural]: [json] + }), + { + "Content-Type": "application/json" + } + ); + Helpers.assert428ForObject(response); + + json[objectVersionProp] = version - 1; + + response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ + [objectTypePlural]: [json] + }), + { + "Content-Type": "application/json", + } + ); + // Outdated object version property + const message = `${_capitalizeFirstLetter(objectType)} has been modified since specified version (expected ${json[objectVersionProp]}, found ${version2})`; + Helpers.assert412ForObject(response, { message: message }); + // Modify object, using object version property + json[objectVersionProp] = version; + + response = await API.userPost( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + JSON.stringify({ + [objectTypePlural]: [json] + }), + { + "Content-Type": "application/json", + } + ); + Helpers.assertStatusCode(response, 200); + Helpers.assert200ForObject(response); + // Version should be incremented on modified object + const version3 = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version3); + assert.isAbove(version3, version2); + // Check library version + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}` + ); + version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + assert.equal(version, version3); + // Check single-object request + response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}?key=${config.apiKey}` + ); + version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + assert.equal(version, version3); + + // TODO: Version should be incremented on deleted item + }; + + const _testMultiObject304NotModified = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}` + ); + + const version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + + response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}`, + { 'If-Modified-Since-Version': version } + ); + Helpers.assertStatusCode(response, 304); + }; + + const _testNewerAndVersionsFormat = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + const xmlArray = []; + + switch (objectType) { + case 'collection': + xmlArray.push(await API.createCollection("Name", false, true)); + xmlArray.push(await API.createCollection("Name", false, true)); + xmlArray.push(await API.createCollection("Name", false, true)); + break; + + case 'item': + xmlArray.push(await API.createItem("book", { + title: "Title" + }, true)); + xmlArray.push(await API.createItem("book", { + title: "Title" + }, true)); + xmlArray.push(await API.createItem("book", { + title: "Title" + }, true)); + break; + + + case 'search': + xmlArray.push(await API.createSearch( + "Name", [{ + condition: "title", + operator: "contains", + value: "test" + }], + true + )); + xmlArray.push(await API.createSearch( + "Name", [{ + condition: "title", + operator: "contains", + value: "test" + }], + true + )); + xmlArray.push(await API.createSearch( + "Name", [{ + condition: "title", + operator: "contains", + value: "test" + }], + true + )); + } + + const objects = []; + while (xmlArray.length > 0) { + const xml = xmlArray.shift(); + const data = API.parseDataFromAtomEntry(xml); + objects.push({ + key: data.key, + version: data.version + }); + } + + const firstVersion = objects[0].version; + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?key=${config.apiKey}&format=versions&newer=${firstVersion}`, { + "Content-Type": "application/json" + } + ); + Helpers.assertStatusCode(response, 200); + const json = JSON.parse(response.data); + assert.ok(json); + assert.lengthOf(Object.keys(json), 2); + const keys = Object.keys(json); + + assert.equal(objects[2].key, keys.shift()); + assert.equal(objects[2].version, json[objects[2].key]); + assert.equal(objects[1].key, keys.shift()); + assert.equal(objects[1].version, json[objects[1].key]); + }; + + const _testUploadUnmodified = async (objectType) => { + let objectTypePlural = API.getPluralObjectType(objectType); + let xml, version, response, data, json; + + switch (objectType) { + case "collection": + xml = await API.createCollection("Name", false, true); + break; + + case "item": + xml = await API.createItem("book", { title: "Title" }, true); + break; + + case "search": + xml = await API.createSearch("Name", "default", true); + break; + } + + version = parseInt(Helpers.xpathEval(xml, "//atom:entry/zapi:version")); + assert.notEqual(0, version); + + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + response = await API.userPut( + config.userID, + `${objectTypePlural}/${data.key}?key=${config.apiKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 204); + assert.equal(version, response.headers["last-modified-version"][0]); + + switch (objectType) { + case "collection": + xml = await API.getCollectionXML(data.key); + break; + + case "item": + xml = await API.getItemXML(data.key); + break; + + case "search": + xml = await API.getSearchXML(data.key); + break; + } + + data = API.parseDataFromAtomEntry(xml); + assert.equal(version, data.version); + }; + + it('testSingleObjectLastModifiedVersion', async function () { + await _testSingleObjectLastModifiedVersion('collection'); + await _testSingleObjectLastModifiedVersion('item'); + await _testSingleObjectLastModifiedVersion('search'); + }); + it('testMultiObjectLastModifiedVersion', async function () { + await _testMultiObjectLastModifiedVersion('collection'); + await _testMultiObjectLastModifiedVersion('item'); + await _testMultiObjectLastModifiedVersion('search'); + }); + + it('testMultiObject304NotModified', async function () { + await _testMultiObject304NotModified('collection'); + await _testMultiObject304NotModified('item'); + await _testMultiObject304NotModified('search'); + await _testMultiObject304NotModified('tag'); + }); + + it('testNewerAndVersionsFormat', async function () { + await _testNewerAndVersionsFormat('collection'); + await _testNewerAndVersionsFormat('item'); + await _testNewerAndVersionsFormat('search'); + }); + + it('testUploadUnmodified', async function () { + await _testUploadUnmodified('collection'); + await _testUploadUnmodified('item'); + await _testUploadUnmodified('search'); + }); + + it('testNewerTags', async function () { + const tags1 = ["a", "aa", "b"]; + const tags2 = ["b", "c", "cc"]; + + const data1 = await API.createItem("book", { + tags: tags1.map((tag) => { + return { tag: tag }; + }) + }, true, 'data'); + + await API.createItem("book", { + tags: tags2.map((tag) => { + return { tag: tag }; + }) + }, true, 'data'); + + // Only newly added tags should be included in newer, + // not previously added tags or tags added to items + let response = await API.userGet( + config.userID, + "tags?key=" + config.apiKey + + "&newer=" + data1.version + ); + Helpers.assertNumResults(response, 2); + + // Deleting an item shouldn't update associated tag versions + response = await API.userDelete( + config.userID, + `items/${data1.key}?key=${config.apiKey}`, + { + "If-Unmodified-Since-Version": data1.version + } + ); + Helpers.assertStatusCode(response, 204); + + response = await API.userGet( + config.userID, + "tags?key=" + config.apiKey + + "&newer=" + data1.version + ); + Helpers.assertNumResults(response, 2); + let libraryVersion = response.headers["last-modified-version"][0]; + + response = await API.userGet( + config.userID, + "tags?key=" + config.apiKey + + "&newer=" + libraryVersion + ); + Helpers.assertNumResults(response, 0); + }); +}); diff --git a/tests/remote_js/test/3/annotationsTest.js b/tests/remote_js/test/3/annotationsTest.js new file mode 100644 index 00000000..af6d9173 --- /dev/null +++ b/tests/remote_js/test/3/annotationsTest.js @@ -0,0 +1,644 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { resetGroups } = require('../../groupsSetup.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('AnnotationsTests', function () { + this.timeout(config.timeout); + let pdfAttachmentKey; + let epubAttachmentKey; + let snapshotAttachmentKey; + + before(async function () { + await API3Before(); + await resetGroups(); + await API.groupClear(config.ownedPrivateGroupID); + + let key = await API.createItem("book", {}, null, 'key'); + let json = await API.createAttachmentItem( + "imported_url", + { contentType: 'application/pdf' }, + key, + null, + 'jsonData' + ); + pdfAttachmentKey = json.key; + + json = await API.createAttachmentItem( + "imported_url", + { contentType: 'application/epub+zip' }, + key, + null, + 'jsonData' + ); + epubAttachmentKey = json.key; + + json = await API.createAttachmentItem( + "imported_url", + { contentType: 'text/html' }, + key, + null, + 'jsonData' + ); + snapshotAttachmentKey = json.key; + }); + + after(async function () { + await API3After(); + }); + + + it('test_should_reject_non_empty_annotationText_for_image_annotation', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'image', + annotationText: 'test', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, "'annotationText' can only be set for highlight and underline annotations"); + }); + + it('test_should_save_a_highlight_annotation_with_parentItem_specified_last', async function () { + let json = { + itemType: 'annotation', + annotationType: 'highlight', + annotationAuthorName: 'First Last', + annotationText: 'This is highlighted text.', + annotationColor: '#ff8c19', + annotationPageLabel: '10', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }), + parentItem: pdfAttachmentKey, + }; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + }); + + it('test_should_trigger_upgrade_error_for_epub_annotation_on_old_clients', async function () { + const json = { + itemType: 'annotation', + parentItem: epubAttachmentKey, + annotationType: 'highlight', + annotationText: 'foo', + annotationSortIndex: '00050|00013029', + annotationColor: '#ff8c19', + annotationPosition: JSON.stringify({ + type: 'FragmentSelector', + conformsTo: 'http://www.idpf.org/epub/linking/cfi/epub-cfi.html', + value: 'epubcfi(/6/4!/4/2[pg-header]/2[pg-header-heading],/1:4,/1:11)' + }) + }; + const response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + const jsonResponse = API.getJSONFromResponse(response).successful[0]; + const annotationKey = jsonResponse.key; + + API.useSchemaVersion(28); + const getResponse = await API.userGet( + config.userID, + `items/${annotationKey}` + ); + const getJson = API.getJSONFromResponse(getResponse); + const jsonData = getJson.data; + assert.property(jsonData, 'invalidProp'); + API.resetSchemaVersion(); + }); + + + it('test_should_not_allow_changing_annotation_type', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: 'This is highlighted text.', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + // Create highlight annotation + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { 'Content-Type': 'application/json' } + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response).successful[0]; + let annotationKey = json.key; + let version = json.version; + + // Try to change to note annotation + json = { + version: version, + annotationType: 'note' + }; + response = await API.userPatch( + config.userID, + `items/${annotationKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assert400(response); + }); + + it('test_should_reject_invalid_color_value', async function () { + const json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: '', + annotationSortIndex: '00015|002431|00000', + annotationColor: 'ff8c19', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6], + ], + }), + }; + const response = await API.userPost( + config.userID, + 'items', + JSON.stringify([json]), + { 'Content-Type': 'application/json' } + ); + Helpers.assert400ForObject( + response, + 'annotationColor must be a hex color (e.g., \'#FF0000\')' + ); + }); + + it('test_should_not_include_authorName_if_empty', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: 'This is highlighted text.', + annotationColor: '#ff8c19', + annotationPageLabel: '10', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + let response = await API.userPost(config.userID, 'items', JSON.stringify([json]), { 'Content-Type': 'application/json' }); + Helpers.assert200ForObject(response); + let jsonResponse = API.getJSONFromResponse(response); + let jsonData = jsonResponse.successful[0].data; + assert.notProperty(jsonData, 'annotationAuthorName'); + }); + + it('test_should_use_default_yellow_if_color_not_specified', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: '', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + let jsonData = json.successful[0].data; + Helpers.assertEquals('#ffd400', jsonData.annotationColor); + }); + + it('test_should_clear_annotation_fields', async function () { + const json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: 'This is highlighted text.', + annotationComment: 'This is a comment.', + annotationSortIndex: '00015|002431|00000', + annotationPageLabel: "5", + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + const response = await API.userPost(config.userID, 'items', JSON.stringify([json]), { "Content-Type": "application/json" }); + Helpers.assert200ForObject(response); + const result = API.getJSONFromResponse(response); + const { key: annotationKey, version } = result.successful[0]; + const patchJson = { + key: annotationKey, + version: version, + annotationComment: '', + annotationPageLabel: '' + }; + const patchResponse = await API.userPatch(config.userID, `items/${annotationKey}`, JSON.stringify(patchJson), { "Content-Type": "application/json" }); + Helpers.assert204(patchResponse); + const itemJson = await API.getItem(annotationKey, this, 'json'); + Helpers.assertEquals('', itemJson.data.annotationComment); + Helpers.assertEquals('', itemJson.data.annotationPageLabel); + }); + + it('test_should_reject_empty_annotationText_for_image_annotation', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'image', + annotationText: '', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + + let response = await API.userPost( + config.userID, + 'items', + JSON.stringify([json]), + { 'Content-Type': 'application/json' }, + ); + + Helpers.assert400ForObject(response, "'annotationText' can only be set for highlight annotations"); + }); + + it('test_should_save_an_ink_annotation', async function () { + const paths = [ + [173.54, 647.25, 175.88, 647.25, 181.32, 647.25, 184.44, 647.25, 191.44, 647.25, 197.67, 647.25, 203.89, 645.7, 206.23, 645.7, 210.12, 644.92, 216.34, 643.36, 218.68], + [92.4075, 245.284, 92.4075, 245.284, 92.4075, 246.034, 91.6575, 248.284, 91.6575, 253.534, 91.6575, 255.034, 91.6575, 261.034, 91.6575, 263.284, 95.4076, 271.535, 99.9077] + ]; + const json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'ink', + annotationColor: '#ff8c19', + annotationPageLabel: '10', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + paths, + width: 2 + }) + }; + const response = await API.userPost(config.userID, "items", JSON.stringify([json]), { "Content-Type": "application/json" }); + Helpers.assert200ForObject(response); + const jsonResponse = API.getJSONFromResponse(response); + const jsonData = jsonResponse.successful[0].data; + Helpers.assertEquals('annotation', jsonData.itemType); + Helpers.assertEquals('ink', jsonData.annotationType); + Helpers.assertEquals('#ff8c19', jsonData.annotationColor); + Helpers.assertEquals('10', jsonData.annotationPageLabel); + Helpers.assertEquals('00015|002431|00000', jsonData.annotationSortIndex); + const position = JSON.parse(jsonData.annotationPosition); + Helpers.assertEquals(123, position.pageIndex); + assert.deepEqual(paths, position.paths); + }); + + it('test_should_save_a_note_annotation', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'note', + annotationComment: 'This is a comment.', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + let jsonResponse = API.getJSONFromResponse(response); + let jsonData = jsonResponse.successful[0].data; + Helpers.assertEquals('annotation', jsonData.itemType.toString()); + Helpers.assertEquals('note', jsonData.annotationType); + Helpers.assertEquals('This is a comment.', jsonData.annotationComment); + Helpers.assertEquals('00015|002431|00000', jsonData.annotationSortIndex); + let position = JSON.parse(jsonData.annotationPosition); + Helpers.assertEquals(123, position.pageIndex); + assert.deepEqual([[314.4, 412.8, 556.2, 609.6]], position.rects); + assert.notProperty(jsonData, 'annotationText'); + }); + + it('test_should_update_annotation_text', async function () { + const json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: 'This is highlighted text.', + annotationComment: '', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + const response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + const jsonResponse = API.getJSONFromResponse(response).successful[0]; + const annotationKey = jsonResponse.key; + const version = jsonResponse.version; + + const updateJson = { + key: annotationKey, + version: version, + annotationText: 'New text' + }; + const updateResponse = await API.userPatch( + config.userID, + `items/${annotationKey}`, + JSON.stringify(updateJson), + { "Content-Type": "application/json" } + ); + Helpers.assert204(updateResponse); + + const getItemResponse = await API.getItem(annotationKey, this, 'json'); + const jsonItemText = getItemResponse.data.annotationText; + Helpers.assertEquals('New text', jsonItemText); + }); + + it('test_should_reject_long_position', async function () { + let rects = []; + for (let i = 0; i <= 13000; i++) { + rects.push(i); + } + let positionJSON = JSON.stringify({ + pageIndex: 123, + rects: [rects], + }); + let json = { + itemType: "annotation", + parentItem: pdfAttachmentKey, + annotationType: "ink", + annotationSortIndex: "00015|002431|00000", + annotationColor: "#ff8c19", + annotationPosition: positionJSON, + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + // TEMP: See note in Item.inc.php + //assert413ForObject( + Helpers.assert400ForObject( + // TODO: Restore once output isn't HTML-encoded + //response, "Annotation position '" . mb_substr(positionJSON, 0, 50) . "…' is too long", 0 + response, + "Annotation position is too long for attachment " + pdfAttachmentKey, + 0 + ); + }); + + it('test_should_truncate_long_text', async function () { + const json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: '这是一个测试。'.repeat(5000), + annotationSortIndex: '00015|002431|00000', + annotationColor: '#ff8c19', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + const response = await API.userPost( + config.userID, + 'items', + JSON.stringify([json]), + { 'Content-Type': 'application/json' } + ); + Helpers.assert200ForObject(response); + const jsonResponse = API.getJSONFromResponse(response); + const jsonData = jsonResponse.successful[0].data; + Helpers.assertEquals(7500, jsonData.annotationText.length); + }); + + it('test_should_update_annotation_comment', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: 'This is highlighted text.', + annotationComment: '', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response).successful[0]; + let annotationKey = json.key, version = json.version; + json = { + key: annotationKey, + version: version, + annotationComment: 'What a highlight!' + }; + response = await API.userPatch( + config.userID, + `items/${annotationKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + json = await API.getItem(annotationKey, this, 'json'); + Helpers.assertEquals('What a highlight!', json.data.annotationComment); + }); + + it('test_should_save_a_highlight_annotation', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationAuthorName: 'First Last', + annotationText: 'This is highlighted text.', + annotationColor: '#ff8c19', + annotationPageLabel: '10', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + let jsonResponse = API.getJSONFromResponse(response); + let jsonData = jsonResponse.successful[0].data; + Helpers.assertEquals('annotation', String(jsonData.itemType)); + Helpers.assertEquals('highlight', jsonData.annotationType); + Helpers.assertEquals('First Last', jsonData.annotationAuthorName); + Helpers.assertEquals('This is highlighted text.', jsonData.annotationText); + Helpers.assertEquals('#ff8c19', jsonData.annotationColor); + Helpers.assertEquals('10', jsonData.annotationPageLabel); + Helpers.assertEquals('00015|002431|00000', jsonData.annotationSortIndex); + let position = JSON.parse(jsonData.annotationPosition); + Helpers.assertEquals(123, position.pageIndex); + assert.deepEqual([[314.4, 412.8, 556.2, 609.6]], position.rects); + }); + + it('test_should_save_an_image_annotation', async function () { + // Create annotation + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'image', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }) + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + let jsonResponse = API.getJSONFromResponse(response); + jsonResponse = jsonResponse.successful[0]; + let jsonData = jsonResponse.data; + Helpers.assertEquals('annotation', jsonData.itemType); + Helpers.assertEquals('image', jsonData.annotationType); + Helpers.assertEquals('00015|002431|00000', jsonData.annotationSortIndex); + let position = JSON.parse(jsonData.annotationPosition); + Helpers.assertEquals(123, position.pageIndex); + assert.deepEqual([[314.4, 412.8, 556.2, 609.6]], position.rects); + assert.notProperty(jsonData, 'annotationText'); + + // Image uploading tested in FileTest + }); + + it('test_should_reject_invalid_sortIndex', async function () { + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'highlight', + annotationText: '', + annotationSortIndex: '0000', + annotationColor: '#ff8c19', + annotationPosition: JSON.stringify({ + pageIndex: 123, + rects: [ + [314.4, 412.8, 556.2, 609.6], + ] + }) + }; + let response = await API.userPost(config.userID, 'items', JSON.stringify([json]), { 'Content-Type': 'application/json' }); + Helpers.assert400ForObject(response, "Invalid sortIndex '0000'", 0); + }); + + it('test_should_reject_long_page_label', async function () { + let label = Helpers.uniqueID(52); + let json = { + itemType: 'annotation', + parentItem: pdfAttachmentKey, + annotationType: 'ink', + annotationSortIndex: '00015|002431|00000', + annotationColor: '#ff8c19', + annotationPageLabel: label, + annotationPosition: { + paths: [] + } + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + // TEMP: See note in Item.inc.php + //Helpers.assert413ForObject( + Helpers.assert400ForObject( + // TODO: Restore once output isn't HTML-encoded + //response, "Annotation page label '" + label.substr(0, 50) + "…' is too long", 0 + response, "Annotation page label is too long for attachment " + pdfAttachmentKey, 0 + ); + }); +}); diff --git a/tests/remote_js/test/3/atomTest.js b/tests/remote_js/test/3/atomTest.js new file mode 100644 index 00000000..07901bbe --- /dev/null +++ b/tests/remote_js/test/3/atomTest.js @@ -0,0 +1,146 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('AtomTests', function () { + this.timeout(config.timeout); + let keyObj = {}; + + before(async function () { + await API3Before(); + let key = await API.createItem("book", { + title: "Title", + creators: [{ + creatorType: "author", + firstName: "First", + lastName: "Last" + }] + }, false, "key"); + keyObj[key] = '
Last, First. Title, n.d.
' + + '{"key":"","version":0,"itemType":"book","title":"Title","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{},"dateAdded":"","dateModified":""}' + + '
'; + key = await API.createItem("book", { + title: "Title 2", + creators: [ + { + creatorType: "author", + firstName: "First", + lastName: "Last" + }, + { + creatorType: "editor", + firstName: "Ed", + lastName: "McEditor" + } + ] + }, false, "key"); + keyObj[key] = '
Last, First. Title 2. Edited by Ed McEditor, n.d.
' + + '{"key":"","version":0,"itemType":"book","title":"Title 2","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"},{"creatorType":"editor","firstName":"Ed","lastName":"McEditor"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{},"dateAdded":"","dateModified":""}' + + '
'; + }); + + after(async function () { + await API3After(); + }); + + + it('testFeedURIs', async function () { + let userID = config.userID; + + let response = await API.userGet(userID, "items?format=atom"); + Helpers.assert200(response); + let xml = API.getXMLFromResponse(response); + let links = Helpers.xpathEval(xml, "//atom:feed/atom:link", true, true); + Helpers.assertEquals( + config.apiURLPrefix + "users/" + userID + "/items?format=atom", + links[0].getAttribute("href") + ); + + // 'order'/'sort' should turn into 'sort'/'direction' + response = await API.userGet(userID, "items?format=atom&order=dateModified&sort=asc"); + Helpers.assert200(response); + xml = API.getXMLFromResponse(response); + links = Helpers.xpathEval(xml, "//atom:feed/atom:link", true, true); + Helpers.assertEquals( + config.apiURLPrefix + "users/" + userID + "/items?direction=asc&format=atom&sort=dateModified", + links[0].getAttribute("href") + ); + }); + + //Requires citation server to run + it('testMultiContent', async function () { + const keys = Object.keys(keyObj); + const keyStr = keys.join(','); + + const response = await API.userGet( + config.userID, + `items?itemKey=${keyStr}&content=bib,json`, + ); + Helpers.assertStatusCode(response, 200); + const xml = API.getXMLFromResponse(response); + Helpers.assertTotalResults(response, keys.length); + + const entries = Helpers.xpathEval(xml, '//atom:entry', true, true); + for (const entry of entries) { + const key = entry.getElementsByTagName("zapi:key")[0].innerHTML; + let content = entry.getElementsByTagName("content")[0].outerHTML; + + // Add namespace prefix (from ) + content = content.replace( + 'Doe and Smith, Title.', + apa: '(Doe & Smith, 2014)', + "https://www.zotero.org/styles/apa": '(Doe & Smith, 2014)', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '[1]' + }, + bib: { + default: '
Doe, Alice, and Bob Smith. Title, 2014.
', + apa: '
Doe, A., & Smith, B. (2014). Title.
', + "https://www.zotero.org/styles/apa": '
Doe, A., & Smith, B. (2014). Title.
', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '
[1]
A. Doe and B. Smith, Title. 2014.
' + } + }, + atom: { + citation: { + default: 'Doe and Smith, Title.', + apa: '(Doe & Smith, 2014)', + "https://www.zotero.org/styles/apa": '(Doe & Smith, 2014)', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '[1]' + }, + bib: { + default: '
Doe, Alice, and Bob Smith. Title, 2014.
', + apa: '
Doe, A., & Smith, B. (2014). Title.
', + "https://www.zotero.org/styles/apa": '
Doe, A., & Smith, B. (2014). Title.
', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '
[1]
A. Doe and B. Smith, Title. 2014.
' + } + } + }; + + key = await API.createItem("book", { + title: "Title 2", + date: "June 24, 2014", + creators: [ + { + creatorType: "author", + firstName: "Jane", + lastName: "Smith" + }, + { + creatorType: "editor", + firstName: "Ed", + lastName: "McEditor" + } + ] + }, null, 'key'); + + + items[key] = { + json: { + citation: { + default: 'Smith, Title 2.', + apa: '(Smith, 2014)', + "https://www.zotero.org/styles/apa": '(Smith, 2014)', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '[1]' + }, + bib: { + default: '
Smith, Jane. Title 2. Edited by Ed McEditor, 2014.
', + apa: '
Smith, J. (2014). Title 2 (E. McEditor, Ed.).
', + "https://www.zotero.org/styles/apa": '
Smith, J. (2014). Title 2 (E. McEditor, Ed.).
', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '
[1]
J. Smith, Title 2. 2014.
' + } + }, + atom: { + citation: { + default: 'Smith, Title 2.', + apa: '(Smith, 2014)', + "https://www.zotero.org/styles/apa": '(Smith, 2014)', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '[1]' + }, + bib: { + default: '
Smith, Jane. Title 2. Edited by Ed McEditor, 2014.
', + apa: '
Smith, J. (2014). Title 2 (E. McEditor, Ed.).
', + "https://www.zotero.org/styles/apa": '
Smith, J. (2014). Title 2 (E. McEditor, Ed.).
', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '
[1]
J. Smith, Title 2. 2014.
' + } + } + }; + + + multiResponses = { + default: '
Doe, Alice, and Bob Smith. Title, 2014.
Smith, Jane. Title 2. Edited by Ed McEditor, 2014.
', + apa: '
Doe, A., & Smith, B. (2014). Title.
Smith, J. (2014). Title 2 (E. McEditor, Ed.).
', + "https://www.zotero.org/styles/apa": '
Doe, A., & Smith, B. (2014). Title.
Smith, J. (2014). Title 2 (E. McEditor, Ed.).
', + "https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl": '
[1]
J. Smith, Title 2. 2014.
[2]
A. Doe and B. Smith, Title. 2014.
' + }; + + multiResponsesLocales = { + fr: '
Doe, Alice, et Bob Smith. Title, 2014.
Smith, Jane. Title 2. Édité par Ed McEditor, 2014.
' + }; + }); + + after(async function () { + await API3After(); + }); + + it('testContentCitationMulti', async function () { + let keys = Object.keys(items); + let keyStr = keys.join(','); + for (let style of styles) { + let response = await API.userGet( + config.userID, + `items?itemKey=${keyStr}&content=citation${style == "default" ? "" : "&style=" + encodeURIComponent(style)}` + ); + Helpers.assert200(response); + Helpers.assertTotalResults(response, keys.length); + let xml = API.getXMLFromResponse(response); + let entries = Helpers.xpathEval(xml, '//atom:entry', true, true); + for (let entry of entries) { + const key = entry.getElementsByTagName("zapi:key")[0].innerHTML; + let content = entry.getElementsByTagName("content")[0].outerHTML; + content = content.replace(' { + const name = "Test Collection"; + const json = await API.createCollection(name, false, true, 'json'); + assert.equal(json.data.name, name); + return json.key; + }; + + + it('testNewSubcollection', async function () { + let parent = await testNewCollection(); + let name = "Test Subcollection"; + + let json = await API.createCollection(name, parent, this, 'json'); + Helpers.assertEquals(name, json.data.name); + Helpers.assertEquals(parent, json.data.parentCollection); + + let response = await API.userGet( + config.userID, + "collections/" + parent + ); + Helpers.assert200(response); + let jsonResponse = API.getJSONFromResponse(response); + Helpers.assertEquals(jsonResponse.meta.numCollections, 1); + }); + + // MySQL FK cascade limit is 15, so 15 would prevent deleting all collections with just the + // libraryID + it('test_should_delete_collection_with_14_levels_below_it', async function () { + let json = await API.createCollection("0", false, this, 'json'); + let topCollectionKey = json.key; + let parentCollectionKey = topCollectionKey; + for (let i = 0; i < 14; i++) { + json = await API.createCollection(`${i}`, parentCollectionKey, this, 'json'); + parentCollectionKey = json.key; + } + const response = await API.userDelete( + config.userID, + "collections?collectionKey=" + topCollectionKey, + { + "If-Unmodified-Since-Version": `${json.version}` + } + ); + Helpers.assert204(response); + }); + + it('testCollectionChildItemError', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + + let key = await API.createItem("book", [], this, 'key'); + let json = await API.createNoteItem("Test Note", key, this, 'jsonData'); + json.collections = [collectionKey]; + + let response = await API.userPut( + config.userID, + `items/${json.key}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert400(response); + Helpers.assertEquals("Child items cannot be assigned to collections", response.data); + }); + + it('test_should_convert_child_attachent_with_embedded_note_in_collection_to_standalone_attachment_while_changing_note', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + let key = await API.createItem("book", { collections: [collectionKey] }, this, 'key'); + let json = await API.createAttachmentItem("linked_url", { note: "Foo" }, key, this, 'jsonData'); + json = { + key: json.key, + version: json.version, + note: "", + collections: [collectionKey], + parentItem: false + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response).successful[0]; + assert.equal(json.data.note, ""); + assert.deepEqual([collectionKey], json.data.collections); + }); + + it('testCollectionItems', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + + let json = await API.createItem("book", { collections: [collectionKey] }, this, 'jsonData'); + let itemKey1 = json.key; + assert.deepEqual([collectionKey], json.collections); + + json = await API.createItem("journalArticle", { collections: [collectionKey] }, this, 'jsonData'); + let itemKey2 = json.key; + assert.deepEqual([collectionKey], json.collections); + + let childItemKey1 = await API.createAttachmentItem("linked_url", {}, itemKey1, this, 'key'); + let childItemKey2 = await API.createAttachmentItem("linked_url", {}, itemKey2, this, 'key'); + + let response = await API.userGet( + config.userID, + `collections/${collectionKey}/items?format=keys` + ); + Helpers.assert200(response); + let keys = response.data.trim().split("\n"); + assert.lengthOf(keys, 4); + assert.include(keys, itemKey1); + assert.include(keys, itemKey2); + assert.include(keys, childItemKey1); + assert.include(keys, childItemKey2); + + response = await API.userGet( + config.userID, + `collections/${collectionKey}/items/top?format=keys` + ); + Helpers.assert200(response); + keys = response.data.trim().split("\n"); + assert.lengthOf(keys, 2); + assert.include(keys, itemKey1); + assert.include(keys, itemKey2); + }); + + + it('test_should_allow_emoji_in_name', async function () { + let name = "🐶"; + let json = await API.createCollection(name, false, this, 'json'); + assert.equal(name, json.data.name); + }); + + it('testCreateKeyedCollections', async function () { + let key1 = Helpers.uniqueID(); + let name1 = "Test Collection 2"; + let name2 = "Test Subcollection"; + + let json = [ + { + key: key1, + version: 0, + name: name1 + }, + { + name: name2, + parentCollection: key1 + } + ]; + + let response = await API.userPost( + config.userID, + "collections", + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + let libraryVersion = response.headers['last-modified-version'][0]; + json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json.successful), 2); + + // Check data in write response + Helpers.assertEquals(json.successful[0].key, json.successful[0].data.key); + Helpers.assertEquals(json.successful[1].key, json.successful[1].data.key); + Helpers.assertEquals(libraryVersion, json.successful[0].version); + Helpers.assertEquals(libraryVersion, json.successful[1].version); + Helpers.assertEquals(libraryVersion, json.successful[0].data.version); + Helpers.assertEquals(libraryVersion, json.successful[1].data.version); + Helpers.assertEquals(name1, json.successful[0].data.name); + Helpers.assertEquals(name2, json.successful[1].data.name); + assert.notOk(json.successful[0].data.parentCollection); + Helpers.assertEquals(key1, json.successful[1].data.parentCollection); + + // Check in separate request, to be safe + let keys = Object.keys(json.successful).map(k => json.successful[k].key); + response = await API.getCollectionResponse(keys); + Helpers.assertTotalResults(response, 2); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(name1, json[0].data.name); + assert.notOk(json[0].data.parentCollection); + Helpers.assertEquals(name2, json[1].data.name); + Helpers.assertEquals(key1, json[1].data.parentCollection); + }); + + it('testUpdateMultipleCollections', async function () { + let collection1Data = await API.createCollection("Test 1", false, this, 'jsonData'); + let collection2Name = "Test 2"; + let collection2Data = await API.createCollection(collection2Name, false, this, 'jsonData'); + + let libraryVersion = await API.getLibraryVersion(); + + // Update with no change, which should still update library version (for now) + let response = await API.userPost( + config.userID, + "collections", + JSON.stringify([ + collection1Data, + collection2Data + ]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert200(response); + // If this behavior changes, remove the pre-increment + Helpers.assertEquals(++libraryVersion, response.headers['last-modified-version'][0]); + let json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json.unchanged), 2); + + Helpers.assertEquals(libraryVersion, await API.getLibraryVersion()); + + // Update + let collection1NewName = "Test 1 Modified"; + let collection2NewParentKey = await API.createCollection("Test 3", false, this, 'key'); + + response = await API.userPost( + config.userID, + "collections", + JSON.stringify([ + { + key: collection1Data.key, + version: collection1Data.version, + name: collection1NewName + }, + { + key: collection2Data.key, + version: collection2Data.version, + parentCollection: collection2NewParentKey + } + ]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert200(response); + libraryVersion = response.headers['last-modified-version'][0]; + json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json.successful), 2); + // Deprecated + assert.lengthOf(Object.keys(json.success), 2); + + // Check data in write response + Helpers.assertEquals(json.successful[0].key, json.successful[0].data.key); + Helpers.assertEquals(json.successful[1].key, json.successful[1].data.key); + Helpers.assertEquals(libraryVersion, json.successful[0].version); + Helpers.assertEquals(libraryVersion, json.successful[1].version); + Helpers.assertEquals(libraryVersion, json.successful[0].data.version); + Helpers.assertEquals(libraryVersion, json.successful[1].data.version); + Helpers.assertEquals(collection1NewName, json.successful[0].data.name); + Helpers.assertEquals(collection2Name, json.successful[1].data.name); + assert.notOk(json.successful[0].data.parentCollection); + Helpers.assertEquals(collection2NewParentKey, json.successful[1].data.parentCollection); + + // Check in separate request, to be safe + let keys = Object.keys(json.successful).map(k => json.successful[k].key); + + response = await API.getCollectionResponse(keys); + Helpers.assertTotalResults(response, 2); + json = API.getJSONFromResponse(response); + // POST follows PATCH behavior, so unspecified values shouldn't change + Helpers.assertEquals(collection1NewName, json[0].data.name); + assert.notOk(json[0].data.parentCollection); + Helpers.assertEquals(collection2Name, json[1].data.name); + Helpers.assertEquals(collection2NewParentKey, json[1].data.parentCollection); + }); + + it('testCollectionItemChange', async function () { + let collectionKey1 = await API.createCollection('Test', false, this, 'key'); + let collectionKey2 = await API.createCollection('Test', false, this, 'key'); + + let json = await API.createItem("book", { + collections: [collectionKey1] + }, this, 'json'); + let itemKey1 = json.key; + let itemVersion1 = json.version; + assert.deepEqual([collectionKey1], json.data.collections); + + json = await API.createItem("journalArticle", { + collections: [collectionKey2] + }, this, 'json'); + let itemKey2 = json.key; + let itemVersion2 = json.version; + assert.deepEqual([collectionKey2], json.data.collections); + + json = await API.getCollection(collectionKey1, this); + assert.deepEqual(1, json.meta.numItems); + + json = await API.getCollection(collectionKey2, this); + Helpers.assertEquals(1, json.meta.numItems); + let collectionData2 = json.data; + + let libraryVersion = await API.getLibraryVersion(); + + // Add items to collection + let response = await API.userPatch( + config.userID, + `items/${itemKey1}`, + JSON.stringify({ + collections: [collectionKey1, collectionKey2] + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion1 + } + ); + Helpers.assert204(response); + + // Item version should change + json = await API.getItem(itemKey1, this); + Helpers.assertEquals(parseInt(libraryVersion) + 1, parseInt(json.version)); + + // Collection timestamp shouldn't change, but numItems should + json = await API.getCollection(collectionKey2, this); + Helpers.assertEquals(2, json.meta.numItems); + Helpers.assertEquals(collectionData2.version, json.version); + collectionData2 = json.data; + + libraryVersion = await API.getLibraryVersion(); + + // Remove collections + response = await API.userPatch( + config.userID, + `items/${itemKey2}`, + JSON.stringify({ + collections: [] + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion2 + } + ); + Helpers.assert204(response); + + // Item version should change + json = await API.getItem(itemKey2, this); + assert.equal(parseInt(libraryVersion) + 1, json.version); + + // Collection timestamp shouldn't change, but numItems should + json = await API.getCollection(collectionKey2, this); + assert.equal(json.meta.numItems, 1); + assert.equal(collectionData2.version, json.version); + + // Check collections arrays and numItems + json = await API.getItem(itemKey1, this); + assert.lengthOf(json.data.collections, 2); + assert.include(json.data.collections, collectionKey1); + assert.include(json.data.collections, collectionKey2); + + json = await API.getItem(itemKey2, this); + assert.lengthOf(json.data.collections, 0); + + json = await API.getCollection(collectionKey1, this); + assert.equal(json.meta.numItems, 1); + + json = await API.getCollection(collectionKey2, this); + assert.equal(json.meta.numItems, 1); + }); + + it('testNewMultipleCollections', async function () { + let json = await API.createCollection("Test Collection 1", false, this, 'jsonData'); + let name1 = "Test Collection 2"; + let name2 = "Test Subcollection"; + let parent2 = json.key; + + json = [ + { + name: name1 + }, + { + name: name2, + parentCollection: parent2 + } + + ]; + + let response = await API.userPost( + config.userID, + "collections", + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + + Helpers.assert200(response); + let libraryVersion = response.headers['last-modified-version'][0]; + let jsonResponse = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(jsonResponse.successful), 2); + // Deprecated + assert.lengthOf(Object.keys(jsonResponse.success), 2); + + // Check data in write response + Helpers.assertEquals(jsonResponse.successful[0].key, jsonResponse.successful[0].data.key); + Helpers.assertEquals(jsonResponse.successful[1].key, jsonResponse.successful[1].data.key); + Helpers.assertEquals(libraryVersion, jsonResponse.successful[0].version); + Helpers.assertEquals(libraryVersion, jsonResponse.successful[1].version); + Helpers.assertEquals(libraryVersion, jsonResponse.successful[0].data.version); + Helpers.assertEquals(libraryVersion, jsonResponse.successful[1].data.version); + Helpers.assertEquals(name1, jsonResponse.successful[0].data.name); + Helpers.assertEquals(name2, jsonResponse.successful[1].data.name); + assert.notOk(jsonResponse.successful[0].data.parentCollection); + Helpers.assertEquals(parent2, jsonResponse.successful[1].data.parentCollection); + + // Check in separate request, to be safe + let keys = Object.keys(jsonResponse.successful).map(k => jsonResponse.successful[k].key); + + response = await API.getCollectionResponse(keys); + + Helpers.assertTotalResults(response, 2); + jsonResponse = API.getJSONFromResponse(response); + Helpers.assertEquals(name1, jsonResponse[0].data.name); + assert.notOk(jsonResponse[0].data.parentCollection); + Helpers.assertEquals(name2, jsonResponse[1].data.name); + Helpers.assertEquals(parent2, jsonResponse[1].data.parentCollection); + }); + + it('test_should_return_409_on_missing_parent_collection', async function () { + let missingCollectionKey = "GDHRG8AZ"; + let json = await API.createCollection("Test", { parentCollection: missingCollectionKey }, this); + Helpers.assert409ForObject(json, `Parent collection ${missingCollectionKey} not found`); + Helpers.assertEquals(missingCollectionKey, json.failed[0].data.collection); + }); + + it('test_should_return_413_if_collection_name_is_too_long', async function () { + const content = "1".repeat(256); + const json = { + name: content + }; + const response = await API.userPost( + config.userID, + "collections", + JSON.stringify([json]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert413ForObject(response); + }); + + it('testNewCollection', async function () { + let name = "Test Collection"; + let json = await API.createCollection(name, false, this, 'json'); + Helpers.assertEquals(name, json.data.name); + return json.key; + }); + + it('testCollectionItemMissingCollection', async function () { + let response = await API.createItem("book", { collections: ["AAAAAAAA"] }, this, 'response'); + Helpers.assert409ForObject(response, "Collection AAAAAAAA not found"); + }); + + it('test_should_move_parent_collection_to_root_if_descendent_of_collection', async function () { + let jsonA = await API.createCollection('A', false, this, 'jsonData'); + // Set B as a child of A + let keyB = await API.createCollection('B', { parentCollection: jsonA.key }, this, 'key'); + + // Try to set B as parent of A + jsonA.parentCollection = keyB; + let response = await API.userPost( + config.userID, + 'collections', + JSON.stringify([jsonA]), + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + assert.equal(json.successful[0].data.parentCollection, keyB); + + let jsonB = await API.getCollection(keyB, this); + assert.notOk(jsonB.data.parentCollection); + }); +}); diff --git a/tests/remote_js/test/3/creatorTest.js b/tests/remote_js/test/3/creatorTest.js new file mode 100644 index 00000000..5b735329 --- /dev/null +++ b/tests/remote_js/test/3/creatorTest.js @@ -0,0 +1,196 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('CreatorTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + + it('test_should_allow_emoji_in_creator_name', async function () { + const char = "🐻"; + const data = { + creators: [ + { + creatorType: "author", + name: char + } + ] + }; + const json = await API.createItem("book", data, true, 'json'); + + assert.equal(json.data.creators[0].name, char); + }); + + it('testCreatorCaseSensitivity', async function () { + await API.createItem("book", { + creators: [ + { + creatorType: "author", + name: "SMITH" + } + ] + }, true, 'json'); + const json = await API.createItem("book", { + creators: [ + { + creatorType: "author", + name: "Smith" + } + ] + }, true, 'json'); + assert.equal(json.data.creators[0].name, 'Smith'); + }); + + it('testCreatorSummaryAtom', async function () { + let xml = await API.createItem("book", { + creators: [ + { + creatorType: "author", + name: "Test" + } + ] + }, null, 'atom'); + let data = API.parseDataFromAtomEntry(xml); + let itemKey = data.key; + let json = JSON.parse(data.content); + + let creatorSummary = Helpers.xpathEval(xml, '//atom:entry/zapi:creatorSummary'); + assert.equal(creatorSummary, "Test"); + + json.creators.push({ + creatorType: "author", + firstName: "Alice", + lastName: "Foo" + }); + + let response = await API.userPut( + config.userID, + `items/${itemKey}`, + JSON.stringify(json) + ); + Helpers.assert204(response); + + xml = await API.getItemXML(itemKey); + creatorSummary = Helpers.xpathEval(xml, '//atom:entry/zapi:creatorSummary'); + assert.equal(creatorSummary, "Test and Foo"); + + data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + + json.creators.push({ + creatorType: "author", + firstName: "Bob", + lastName: "Bar" + }); + + response = await API.userPut( + config.userID, + `items/${itemKey}`, + JSON.stringify(json) + ); + Helpers.assert204(response); + + xml = await API.getItemXML(itemKey); + creatorSummary = Helpers.xpathEval(xml, '//atom:entry/zapi:creatorSummary'); + assert.equal(creatorSummary, "Test et al."); + }); + + + it('testCreatorSummaryJSON', async function () { + let json = await API.createItem('book', { + creators: [{ + creatorType: 'author', + name: 'Test' + }] + }, true, 'json'); + const itemKey = json.key; + + assert.equal(json.meta.creatorSummary, 'Test'); + + json = json.data; + json.creators.push({ + creatorType: 'author', + firstName: 'Alice', + lastName: 'Foo' + }); + + const response = await API.userPut( + config.userID, + `items/${itemKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assert204(response); + + json = await API.getItem(itemKey, true, 'json'); + assert.equal(json.meta.creatorSummary, 'Test and Foo'); + + json = json.data; + json.creators.push({ + creatorType: 'author', + firstName: 'Bob', + lastName: 'Bar' + }); + + const response2 = await API.userPut( + config.userID, + `items/${itemKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assert204(response2); + + json = await API.getItem(itemKey, true, 'json'); + assert.equal(json.meta.creatorSummary, 'Test et al.'); + }); + + it('testEmptyCreator', async function () { + let data = { + creators: [ + { + creatorType: "author", + name: "\uFEFF" + } + ] + }; + let response = await API.createItem("book", data, true, 'json'); + assert.notProperty(response.meta, 'creatorSummary'); + }); + + it('test_should_add_creator_with_correct_case', async function () { + // Create two items with lowercase + let data = { + creators: [ + { + creatorType: "author", + name: "test" + } + ] + }; + await API.createItem("book", data); + await API.createItem("book", data); + + // Create capitalized + let json = await API.createItem("book", { + creators: [ + { + creatorType: "author", + name: "Test" + } + ] + }, true, 'json'); + + assert.equal(json.data.creators[0].name, "Test"); + }); +}); diff --git a/tests/remote_js/test/3/exportTest.js b/tests/remote_js/test/3/exportTest.js new file mode 100644 index 00000000..b0bac6b6 --- /dev/null +++ b/tests/remote_js/test/3/exportTest.js @@ -0,0 +1,181 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('ExportTests', function () { + this.timeout(config.timeout); + let items = {}; + let multiResponses; + let formats = ['bibtex', 'ris', 'csljson']; + + before(async function () { + await API3Before(); + await API.userClear(config.userID); + + // Create test data + let key = await API.createItem("book", { + title: "Title", + date: "January 1, 2014", + accessDate: "2019-05-23T01:23:45Z", + creators: [ + { + creatorType: "author", + firstName: "First", + lastName: "Last" + } + ] + }, null, 'key'); + items[key] = { + bibtex: "\n@book{last_title_2014,\n title = {Title},\n urldate = {2019-05-23},\n author = {Last, First},\n month = jan,\n year = {2014},\n}\n", + ris: "TY - BOOK\r\nTI - Title\r\nAU - Last, First\r\nDA - 2014/01/01/\r\nPY - 2014\r\nY2 - 2019/05/23/01:23:45\r\nER - \r\n\r\n", + csljson: { + id: config.libraryID + "/" + key, + type: 'book', + title: 'Title', + author: [ + { + family: 'Last', + given: 'First' + } + ], + issued: { + 'date-parts': [ + ["2014", 1, 1] + ] + }, + accessed: { + 'date-parts': [ + [2019, 5, 23] + ] + } + } + }; + + key = await API.createItem("book", { + title: "Title 2", + date: "June 24, 2014", + creators: [ + { + creatorType: "author", + firstName: "First", + lastName: "Last" + }, + { + creatorType: "editor", + firstName: "Ed", + lastName: "McEditor" + } + ] + }, null, 'key'); + items[key] = { + bibtex: "\n@book{last_title_2014,\n title = {Title 2},\n author = {Last, First},\n editor = {McEditor, Ed},\n month = jun,\n year = {2014},\n}\n", + ris: "TY - BOOK\r\nTI - Title 2\r\nAU - Last, First\r\nA3 - McEditor, Ed\r\nDA - 2014/06/24/\r\nPY - 2014\r\nER - \r\n\r\n", + csljson: { + id: config.libraryID + "/" + key, + type: 'book', + title: 'Title 2', + author: [ + { + family: 'Last', + given: 'First' + } + ], + editor: [ + { + family: 'McEditor', + given: 'Ed' + } + ], + issued: { + 'date-parts': [ + ['2014', 6, 24] + ] + } + } + }; + + multiResponses = { + bibtex: { + contentType: "application/x-bibtex", + content: "\n@book{last_title_2014,\n title = {Title 2},\n author = {Last, First},\n editor = {McEditor, Ed},\n month = jun,\n year = {2014},\n}\n\n@book{last_title_2014-1,\n title = {Title},\n urldate = {2019-05-23},\n author = {Last, First},\n month = jan,\n year = {2014},\n}\n" + }, + ris: { + contentType: "application/x-research-info-systems", + content: "TY - BOOK\r\nTI - Title 2\r\nAU - Last, First\r\nA3 - McEditor, Ed\r\nDA - 2014/06/24/\r\nPY - 2014\r\nER - \r\n\r\nTY - BOOK\r\nTI - Title\r\nAU - Last, First\r\nDA - 2014/01/01/\r\nPY - 2014\r\nY2 - 2019/05/23/01:23:45\r\nER - \r\n\r\n" + }, + csljson: { + contentType: "application/vnd.citationstyles.csl+json", + content: { + items: [ + items[Object.keys(items)[1]].csljson, + items[Object.keys(items)[0]].csljson + ] + } + } + }; + }); + + after(async function () { + await API3After(); + }); + + it('testExportInclude', async function () { + for (let format of formats) { + let response = await API.userGet( + config.userID, + `items?include=${format}`, + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + for (let obj of json) { + assert.deepEqual(obj[format], items[obj.key][format]); + } + } + }); + + it('testExportFormatSingle', async function () { + for (const format of formats) { + for (const [key, expected] of Object.entries(items)) { + const response = await API.userGet( + config.userID, + `items/${key}?format=${format}` + ); + Helpers.assert200(response); + let body = response.data; + + // TODO: Remove in APIv4 + if (format === 'csljson') { + body = JSON.parse(body); + body = body.items[0]; + } + assert.deepEqual(expected[format], body); + } + } + }); + + it('testExportFormatMultiple', async function () { + for (let format of formats) { + const response = await API.userGet( + config.userID, + `items?format=${format}` + ); + Helpers.assert200(response); + Helpers.assertContentType( + response, + multiResponses[format].contentType + ); + let body = response.data; + if (typeof multiResponses[format].content == 'object') { + body = JSON.parse(body); + } + assert.deepEqual( + multiResponses[format].content, + body + ); + } + }); +}); diff --git a/tests/remote_js/test/3/fileTest.js b/tests/remote_js/test/3/fileTest.js new file mode 100644 index 00000000..46b6ad5a --- /dev/null +++ b/tests/remote_js/test/3/fileTest.js @@ -0,0 +1,2296 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); +const { S3Client, DeleteObjectsCommand, PutObjectCommand } = require("@aws-sdk/client-s3"); +const fs = require('fs'); +const HTTP = require('../../httpHandler.js'); +const crypto = require('crypto'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +const JSZIP = require("jszip"); + +describe('FileTestTests', function () { + this.timeout(config.timeout); + let toDelete = []; + const s3Client = new S3Client({ region: "us-east-1" }); + + before(async function () { + await API3Before(); + try { + fs.mkdirSync("./work"); + } + catch {} + }); + + after(async function () { + await API3After(); + fs.rm("./work", { recursive: true, force: true }, (e) => { + if (e) console.log(e); + }); + if (toDelete.length > 0) { + const commandInput = { + Bucket: config.s3Bucket, + Delete: { + Objects: toDelete.map((x) => { + return { Key: x }; + }) + } + }; + const command = new DeleteObjectsCommand(commandInput); + await s3Client.send(command); + } + }); + + beforeEach(async () => { + API.useAPIKey(config.apiKey); + }); + + const testGetFile = async () => { + const addFileData = await testAddFileExisting(); + + // Get in view mode + let response = await API.userGet( + config.userID, + `items/${addFileData.key}/file/view` + ); + Helpers.assert302(response); + const location = response.headers.location[0]; + Helpers.assertRegExp(/^https?:\/\/[^/]+\/[a-zA-Z0-9%]+\/[a-f0-9]{64}\/test_/, location); + const filenameEncoded = encodeURIComponent(addFileData.filename); + assert.equal(filenameEncoded, location.substring(location.length - filenameEncoded.length)); + + // Get from view mode + const viewModeResponse = await HTTP.get(location); + Helpers.assert200(viewModeResponse); + assert.equal(addFileData.md5, Helpers.md5(viewModeResponse.data)); + + // Get in download mode + response = await API.userGet( + config.userID, + `items/${addFileData.key}/file` + ); + Helpers.assert302(response); + + // Get from S3 + const downloadModeLocation = response.headers.location[0]; + const s3Response = await HTTP.get(downloadModeLocation); + Helpers.assert200(s3Response); + assert.equal(addFileData.md5, Helpers.md5(s3Response.data)); + + return { + key: addFileData.key, + response: s3Response + }; + }; + + it('testAddFileLinkedAttachment', async function () { + let key = await API.createAttachmentItem("linked_file", [], false, this, 'key'); + + let file = "./work/file"; + let fileContents = Helpers.getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let mtime = fs.statSync(file).mtimeMs; + let size = fs.statSync(file).size; + let contentType = "text/plain"; + let charset = "utf-8"; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: contentType, + charset: charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert400(response); + }); + + it('testAddFileFormDataFullParams', async function () { + let json = await API.createAttachmentItem("imported_file", [], false, this, 'json'); + let attachmentKey = json.key; + + await new Promise(r => setTimeout(r, 2000)); + + let originalVersion = json.version; + let file = "./work/file"; + let fileContents = Helpers.getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let size = parseInt(fs.statSync(file).size); + let mtime = parseInt(fs.statSync(file).mtimeMs); + let contentType = "text/plain"; + let charset = "utf-8"; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${attachmentKey}/file`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: contentType, + charset: charset, + params: 1 + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.ok(json); + toDelete.push(hash); + + // Generate form-data -- taken from S3::getUploadPostData() + let boundary = "---------------------------" + Helpers.md5(Helpers.uniqueID()); + let prefix = ""; + for (let key in json.params) { + prefix += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"\r\n\r\n" + json.params[key] + "\r\n"; + } + prefix += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n"; + let suffix = "\r\n--" + boundary + "--"; + // Upload to S3 + response = await HTTP.post( + json.url, + prefix + fileContents + suffix, + { + "Content-Type": "multipart/form-data; boundary=" + boundary + } + ); + Helpers.assert201(response); + + // Register upload + response = await API.userPost( + config.userID, + `items/${attachmentKey}/file`, + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${attachmentKey}` + ); + json = API.getJSONFromResponse(response).data; + assert.equal(hash, json.md5); + assert.equal(filename, json.filename); + assert.equal(mtime, json.mtime); + assert.equal(contentType, json.contentType); + assert.equal(charset, json.charset); + + // Make sure version has changed + assert.notEqual(originalVersion, json.version); + }); + + + const generateZip = async (file, fileContents, archiveName) => { + const zip = new JSZIP(); + + zip.file(file, fileContents); + zip.file("file.css", Helpers.getRandomUnicodeString()); + + const content = await zip.generateAsync({ + type: "nodebuffer", + compression: "DEFLATE", + compressionOptions: { level: 1 } + }); + fs.writeFileSync(archiveName, content); + + // Because when the file is sent, the buffer is stringified, we have to hash the stringified + // fileContents and get the size of stringified buffer here, otherwise they wont match. + return { + hash: Helpers.md5(content.toString()), + zipSize: Buffer.from(content.toString()).byteLength, + fileContent: fs.readFileSync(archiveName) + }; + }; + + it('testExistingFileWithOldStyleFilename', async function () { + let fileContents = Helpers.getRandomUnicodeString(); + let hash = Helpers.md5(fileContents); + let filename = 'test.txt'; + let size = fileContents.length; + + let parentKey = await API.createItem("book", false, this, 'key'); + let json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'jsonData'); + let key = json.key; + let mtime = Date.now(); + let contentType = 'text/plain'; + let charset = 'utf-8'; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: size, + mtime, + contentType, + charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isOk(json); + + // Upload to old-style location + toDelete.push(`${hash}/${filename}`); + toDelete.push(hash); + const putCommand = new PutObjectCommand({ + Bucket: config.s3Bucket, + Key: `${hash}/${filename}`, + Body: fileContents + }); + await s3Client.send(putCommand); + + // Register upload + response = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${json.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + // The file should be accessible on the item at the old-style location + response = await API.userGet( + config.userID, + `items/${key}/file` + ); + Helpers.assert302(response); + let location = response.headers.location[0]; + let matches = location.match(/^https:\/\/(?:[^/]+|.+config.s3Bucket)\/([a-f0-9]{32})\/test.txt\?/); + Helpers.assertEquals(2, matches.length); + Helpers.assertEquals(hash, matches[1]); + + // Get upload authorization for the same file and filename on another item, which should + // result in 'exists', even though we uploaded to the old-style location + parentKey = await API.createItem("book", false, this, 'key'); + json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'jsonData'); + + key = json.key; + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: size, + mtime, + contentType, + charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + let postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + Helpers.assertEquals(1, postJSON.exists); + + // Get in download mode + response = await API.userGet( + config.userID, + `items/${key}/file` + ); + Helpers.assert302(response); + location = response.headers.location[0]; + matches = location.match(/^https:\/\/(?:[^/]+|.+config.s3Bucket)\/([a-f0-9]{32})\/test.txt\?/); + Helpers.assertEquals(2, matches.length); + Helpers.assertEquals(hash, matches[1]); + + // Get from S3 + response = await HTTP.get(location); + Helpers.assert200(response); + Helpers.assertEquals(fileContents, response.data); + Helpers.assertEquals(`${contentType}; charset=${charset}`, response.headers['content-type'][0]); + + // Get upload authorization for the same file and different filename on another item, + // which should result in 'exists' and a copy of the file to the hash-only location + parentKey = await API.createItem("book", false, this, 'key'); + json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'jsonData'); + + key = json.key; + // Also use a different content type + contentType = 'application/x-custom'; + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + filename: "test2.txt", + filesize: size, + mtime, + contentType + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + Helpers.assertEquals(1, postJSON.exists); + + // Get in download mode + response = await API.userGet( + config.userID, + `items/${key}/file` + ); + Helpers.assert302(response); + location = response.headers.location[0]; + matches = location.match(/^https:\/\/(?:[^/]+|.+config.s3Bucket)\/([a-f0-9]{32})\?/); + Helpers.assertEquals(2, matches.length); + Helpers.assertEquals(hash, matches[1]); + + // Get from S3 + response = await HTTP.get(location); + Helpers.assert200(response); + Helpers.assertEquals(fileContents, response.data); + Helpers.assertEquals(contentType, response.headers['content-type'][0]); + }); + + const testAddFileFormDataFull = async () => { + let parentKey = await API.createItem("book", false, this, 'key'); + let json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'json'); + let attachmentKey = json.key; + + let file = "./work/file"; + let fileContents = Helpers.getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let mtime = fs.statSync(file).mtime * 1000; + let size = fs.statSync(file).size; + let contentType = "text/plain"; + let charset = "utf-8"; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${attachmentKey}/file`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: size, + mtime: mtime, + contentType: contentType, + charset: charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isOk(json); + toDelete.push(`${hash}`); + + // Upload wrong contents to S3 + const wrongContent = fileContents.split('').reverse().join(""); + response = await HTTP.post( + json.url, + json.prefix + wrongContent + json.suffix, + { + "Content-Type": `${json.contentType}` + } + ); + Helpers.assert400(response); + assert.include(response.data, "The Content-MD5 you specified did not match what we received."); + + // Upload to S3 + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": `${json.contentType}` + } + ); + Helpers.assert201(response); + + // Register upload + + // No If-None-Match + response = await API.userPost( + config.userID, + `items/${attachmentKey}/file`, + `upload=${json.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + } + ); + Helpers.assert428(response); + + // Invalid upload key + response = await API.userPost( + config.userID, + `items/${attachmentKey}/file`, + `upload=invalidUploadKey`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert400(response); + + response = await API.userPost( + config.userID, + `items/${attachmentKey}/file`, + `upload=${json.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${attachmentKey}` + ); + json = API.getJSONFromResponse(response).data; + + assert.equal(hash, json.md5); + assert.equal(filename, json.filename); + assert.equal(mtime, json.mtime); + assert.equal(contentType, json.contentType); + assert.equal(charset, json.charset); + + return { + key: attachmentKey, + json: json, + size: size + }; + }; + + it('testAddFileFormDataAuthorizationErrors', async function () { + const parentKey = await API.createAttachmentItem("imported_file", [], false, this, 'key'); + const fileContents = Helpers.getRandomUnicodeString(); + const hash = Helpers.md5(fileContents); + const mtime = Date.now(); + const size = fileContents.length; + const filename = `test_${fileContents}`; + + const fileParams = { + md5: hash, + filename, + filesize: size, + mtime, + contentType: "text/plain", + charset: "utf-8" + }; + + // Check required params + const requiredParams = ["md5", "filename", "filesize", "mtime"]; + for (let i = 0; i < requiredParams.length; i++) { + const exclude = requiredParams[i]; + const response = await API.userPost( + config.userID, + `items/${parentKey}/file`, + Helpers.implodeParams(fileParams, [exclude]), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + }); + Helpers.assert400(response); + } + + // Seconds-based mtime + const fileParams2 = { ...fileParams, mtime: Math.round(mtime / 1000) }; + const _ = await API.userPost( + config.userID, + `items/${parentKey}/file`, + Helpers.implodeParams(fileParams2), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + }); + // TODO: Enable this test when the dataserver enforces it + //Helpers.assert400(response2); + //assert.equal('mtime must be specified in milliseconds', response2.data); + + // Invalid If-Match + const response3 = await API.userPost( + config.userID, + `items/${parentKey}/file`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": Helpers.md5("invalidETag") + }); + Helpers.assert412(response3); + + // Missing If-None-Match + const response4 = await API.userPost( + config.userID, + `items/${parentKey}/file`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded" + }); + Helpers.assert428(response4); + + // Invalid If-None-Match + const response5 = await API.userPost( + config.userID, + `items/${parentKey}/file}`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "invalidETag" + }); + Helpers.assert400(response5); + }); + + + it('testAddFilePartial', async function () { + const getFileData = await testGetFile(); + const response = await API.userGet( + config.userID, + `items/${getFileData.key}` + ); + let json = API.getJSONFromResponse(response).data; + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const originalVersion = json.version; + + const oldFilename = "./work/old"; + const fileContents = getFileData.response.data; + fs.writeFileSync(oldFilename, fileContents); + + const newFilename = "./work/new"; + const patchFilename = "./work/patch"; + + const algorithms = { + bsdiff: `bsdiff ${oldFilename} ${newFilename} ${patchFilename}`, + xdelta: `xdelta -f -e -9 -S djw -s ${oldFilename} ${newFilename} ${patchFilename}`, + vcdiff: `vcdiff encode -dictionary ${oldFilename} -target ${newFilename} -delta ${patchFilename}`, + }; + + for (let [algo, cmd] of Object.entries(algorithms)) { + // Create random contents + fs.writeFileSync(newFilename, Helpers.getRandomUnicodeString() + Helpers.uniqueID()); + const newHash = Helpers.md5File(newFilename); + + // Get upload authorization + const fileParams = { + md5: newHash, + filename: `test_${fileContents}`, + filesize: fs.statSync(newFilename).size, + mtime: parseInt(fs.statSync(newFilename).mtimeMs), + contentType: "text/plain", + charset: "utf-8", + }; + + const postResponse = await API.userPost( + config.userID, + `items/${getFileData.key}/file`, + Helpers.implodeParams(fileParams), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": Helpers.md5File(oldFilename), + } + ); + Helpers.assert200(postResponse); + let json = JSON.parse(postResponse.data); + assert.isOk(json); + try { + await exec(cmd); + } + catch { + console.log("Warning: Could not run " + algo); + continue; + } + + const patch = fs.readFileSync(patchFilename); + assert.notEqual("", patch.toString()); + + toDelete.push(newHash); + + // Upload patch file + let response = await API.userPatch( + config.userID, + `items/${getFileData.key}/file?algorithm=${algo}&upload=${json.uploadKey}`, + patch, + { + "If-Match": Helpers.md5File(oldFilename), + } + ); + Helpers.assert204(response); + + fs.rmSync(patchFilename); + fs.renameSync(newFilename, oldFilename); + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${getFileData.key}` + ); + json = API.getJSONFromResponse(response).data; + + Helpers.assertEquals(fileParams.md5, json.md5); + Helpers.assertEquals(fileParams.mtime, json.mtime); + Helpers.assertEquals(fileParams.contentType, json.contentType); + Helpers.assertEquals(fileParams.charset, json.charset); + + // Make sure version has changed + assert.notEqual(originalVersion, json.version); + + // Verify file on S3 + const fileResponse = await API.userGet( + config.userID, + `items/${getFileData.key}/file` + ); + Helpers.assert302(fileResponse); + const location = fileResponse.headers.location[0]; + + const getFileResponse = await HTTP.get(location); + Helpers.assert200(getFileResponse); + Helpers.assertEquals(fileParams.md5, Helpers.md5(getFileResponse.data)); + Helpers.assertEquals( + `${fileParams.contentType}${fileParams.contentType && fileParams.charset ? `; charset=${fileParams.charset}` : "" + }`, + getFileResponse.headers["content-type"][0] + ); + } + }); + + const testAddFileExisting = async () => { + const addFileData = await testAddFileFormDataFull(); + const key = addFileData.key; + const json = addFileData.json; + const md5 = json.md5; + const size = addFileData.size; + + // Get upload authorization + let response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: json.md5, + filename: json.filename, + filesize: size, + mtime: json.mtime, + contentType: json.contentType, + charset: json.charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": json.md5 + } + ); + Helpers.assert200(response); + let postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + assert.equal(1, postJSON.exists); + + // Get upload authorization for existing file with different filename + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: json.md5, + filename: json.filename + "等", // Unicode 1.1 character, to test signature generation + filesize: size, + mtime: json.mtime, + contentType: json.contentType, + charset: json.charset + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": json.md5 + } + ); + Helpers.assert200(response); + postJSON = JSON.parse(response.data); + assert.isOk(postJSON); + assert.equal(1, postJSON.exists); + + const testResult = { + key: key, + md5: md5, + filename: json.filename + "等" + }; + return testResult; + }; + + + it('testAddFileClientV4Zip', async function () { + await API.userClear(config.userID); + + const auth = { + username: config.username, + password: config.password, + }; + + // Get last storage sync + const response1 = await API.userGet( + config.userID, + 'laststoragesync?auth=1', + {}, + auth + ); + Helpers.assert404(response1); + + const json1 = await API.createItem('book', false, this, 'jsonData'); + let key = json1.key; + + const fileContentType = 'text/html'; + const fileCharset = 'UTF-8'; + const fileFilename = 'file.html'; + const fileModtime = Date.now(); + + const json2 = await API.createAttachmentItem('imported_url', [], key, this, 'jsonData'); + key = json2.key; + json2.contentType = fileContentType; + json2.charset = fileCharset; + json2.filename = fileFilename; + + const response2 = await API.userPut( + config.userID, + `items/${key}`, + JSON.stringify(json2), + { + 'Content-Type': 'application/json', + } + ); + Helpers.assert204(response2); + const originalVersion = response2.headers['last-modified-version'][0]; + + // Get file info + const response3 = await API.userGet( + config.userID, + `items/${json2.key}/file?auth=1&iskey=1&version=1&info=1`, + {}, + auth + ); + Helpers.assert404(response3); + + + const { hash, zipSize, fileContent } = await generateZip(fileFilename, Helpers.getRandomUnicodeString(), `work/${key}.zip`); + + const filename = `${key}.zip`; + + // Get upload authorization + const response4 = await API.userPost( + config.userID, + `items/${json2.key}/file?auth=1&iskey=1&version=1`, + Helpers.implodeParams({ + md5: hash, + filename: filename, + filesize: zipSize, + mtime: fileModtime, + zip: 1, + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert200(response4); + Helpers.assertContentType(response4, 'application/xml'); + const xml = API.getXMLFromResponse(response4); + toDelete.push(`${hash}`); + const xmlParams = xml.getElementsByTagName('params')[0]; + const urlComponent = xml.getElementsByTagName('url')[0]; + const keyComponent = xml.getElementsByTagName('key')[0]; + let url = urlComponent.innerHTML; + const boundary = `---------------------------${Helpers.uniqueID()}`; + let postData = ''; + + for (let child of xmlParams.children) { + const key = child.tagName; + const val = child.innerHTML; + postData += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${val}\r\n`; + } + postData += `--${boundary}\r\nContent-Disposition: form-data; name="file"\r\n\r\n${fileContent}\r\n`; + postData += `--${boundary}--`; + + // Upload to S3 + const response5 = await HTTP.post(`${url}`, postData, { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }); + Helpers.assert201(response5); + + // Register upload + const response6 = await API.userPost( + config.userID, + `items/${json2.key}/file?auth=1&iskey=1&version=1`, + `update=${keyComponent.innerHTML}&mtime=${fileModtime}`, + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert204(response6); + + // Verify attachment item metadata + const response7 = await API.userGet(config.userID, `items/${json2.key}`); + const json3 = API.getJSONFromResponse(response7).data; + // Make sure attachment item version hasn't changed (or else the client + // will get a conflict when it tries to update the metadata) + Helpers.assertEquals(originalVersion, json3.version); + Helpers.assertEquals(hash, json3.md5); + Helpers.assertEquals(fileFilename, json3.filename); + Helpers.assertEquals(fileModtime, json3.mtime); + + const response8 = await API.userGet( + config.userID, + 'laststoragesync?auth=1', + {}, + { + username: config.username, + password: config.password, + } + ); + Helpers.assert200(response8); + const mtime = response8.data; + Helpers.assertRegExp(/^[0-9]{10}$/, mtime); + + // File exists + const response9 = await API.userPost( + config.userID, + `items/${json2.key}/file?auth=1&iskey=1&version=1`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: zipSize, + mtime: fileModtime + 1000, + zip: 1, + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert200(response9); + Helpers.assertContentType(response9, 'application/xml'); + Helpers.assertEquals('', response9.data); + + // Make sure attachment version still hasn't changed + const response10 = await API.userGet(config.userID, `items/${json2.key}`); + const json4 = API.getJSONFromResponse(response10).data; + Helpers.assertEquals(originalVersion, json4.version); + }); + + it('test_should_not_allow_anonymous_access_to_file_in_public_closed_group_with_library_reading_for_all', async function () { + let file = "work/file"; + let fileContents = Helpers.getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + let hash = Helpers.md5File(file); + let filename = `test_${fileContents}`; + let mtime = parseInt(fs.statSync(file).mtimeMs); + let size = fs.statSync(file).size; + + let groupID = await API.createGroup({ + owner: config.userID, + type: "PublicClosed", + name: Helpers.uniqueID(14), + libraryReading: "all", + fileEditing: "members", + }); + + let parentKey = await API.groupCreateItem(groupID, "book", false, this, "key"); + let attachmentKey = await API.groupCreateAttachmentItem( + groupID, + "imported_file", + { + contentType: "text/plain", + charset: "utf-8", + }, + parentKey, + this, + "key" + ); + + // Get authorization + let response = await API.groupPost( + groupID, + `items/${attachmentKey}/file`, + Helpers.implodeParams({ + md5: hash, + mtime, + filename, + filesize: size, + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*", + }, + + ); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + + toDelete.push(hash); + + // + // Upload to S3 + // + response = await HTTP.post(json.url, `${json.prefix}${fileContents}${json.suffix}`, { + + "Content-Type": `${json.contentType}`, + }, + ); + Helpers.assert201(response); + + // Successful registration + response = await API.groupPost( + groupID, + `items/${attachmentKey}/file`, + `upload=${json.uploadKey}`, + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*", + }, + + ); + Helpers.assert204(response); + + response = await API.get(`groups/${groupID}/items/${attachmentKey}/file`); + Helpers.assert302(response); + response = await API.get(`groups/${groupID}/items/${attachmentKey}/file/view`); + Helpers.assert302(response); + response = await API.get(`groups/${groupID}/items/${attachmentKey}/file/view/url`); + Helpers.assert200(response); + + API.useAPIKey(""); + response = await API.get(`groups/${groupID}/items/${attachmentKey}/file`); + Helpers.assert404(response); + response = await API.get(`groups/${groupID}/items/${attachmentKey}/file/view`); + Helpers.assert404(response); + response = await API.get(`groups/${groupID}/items/${attachmentKey}/file/view/url`); + Helpers.assert404(response); + + await API.deleteGroup(groupID); + }); + + it('test_should_include_best_attachment_link_on_parent_for_imported_url', async function () { + let json = await API.createItem("book", false, this, 'json'); + assert.equal(0, json.meta.numChildren); + let parentKey = json.key; + + json = await API.createAttachmentItem("imported_url", [], parentKey, this, 'json'); + let attachmentKey = json.key; + let version = json.version; + + let filename = "test.html"; + let mtime = Date.now(); + let fileContents = Helpers.getRandomUnicodeString(); + const zipData = await generateZip("test.html", Helpers.getRandomUnicodeString(), `work/test.html.zip`); + let md5 = Helpers.md5(fileContents); + + // Create attachment item + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([ + { + key: attachmentKey, + contentType: "text/html" + } + ]), + { + + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + + ); + Helpers.assert200ForObject(response); + + // 'attachment' link shouldn't appear if no uploaded file + response = await API.userGet( + config.userID, + "items/" + parentKey + ); + json = API.getJSONFromResponse(response); + assert.notProperty(json.links, 'attachment'); + + // Get upload authorization + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + Helpers.implodeParams({ + md5: md5, + mtime: mtime, + filename: filename, + filesize: zipData.zipSize, + zipMD5: zipData.hash, + zipFilename: "work/test.html.zip" + }), + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": '*' + } + + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + // If file doesn't exist on S3, upload + if (!json.exists) { + response = await HTTP.post( + json.url, + json.prefix + zipData.fileContent + json.suffix, + { "Content-Type": json.contentType } + ); + Helpers.assert201(response); + + // Post-upload file registration + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + "upload=" + json.uploadKey, + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + + ); + Helpers.assert204(response); + } + toDelete.push(zipData.hash); + + // 'attachment' link should now appear + response = await API.userGet( + config.userID, + "items/" + parentKey + ); + json = API.getJSONFromResponse(response); + assert.property(json.links, 'attachment'); + assert.property(json.links.attachment, 'href'); + assert.equal('application/json', json.links.attachment.type); + assert.equal('text/html', json.links.attachment.attachmentType); + assert.notProperty(json.links.attachment, 'attachmentSize'); + }); + + it('testClientV5ShouldRejectFileSizeMismatch', async function () { + await API.userClear(config.userID); + + const file = 'work/file'; + const fileContents = Helpers.getRandomUnicodeString(); + const contentType = 'text/plain'; + const charset = 'utf-8'; + fs.writeFileSync(file, fileContents); + const hash = Helpers.md5File(file); + const filename = `test_${fileContents}`; + const mtime = fs.statSync(file).mtimeMs; + let size = 0; + + const json = await API.createAttachmentItem('imported_file', { + contentType, + charset + }, false, this, 'jsonData'); + const key = json.key; + + // Get authorization + const response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime, + filename, + filesize: size + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + 'If-None-Match': '*' + } + ); + Helpers.assert200(response); + const jsonObj = API.getJSONFromResponse(response); + + // Try to upload to S3, which should fail + const s3Response = await HTTP.post( + jsonObj.url, + jsonObj.prefix + fileContents + jsonObj.suffix, + { + 'Content-Type': jsonObj.contentType + } + ); + Helpers.assert400(s3Response); + assert.include( + s3Response.data, + 'Your proposed upload exceeds the maximum allowed size' + ); + }); + + it('test_updating_attachment_hash_should_clear_associated_storage_file', async function () { + let file = "work/file"; + let fileContents = Helpers.getRandomUnicodeString(); + let contentType = "text/html"; + let charset = "utf-8"; + + fs.writeFileSync(file, fileContents); + + let hash = Helpers.md5File(file); + let filename = "test_" + fileContents; + let mtime = parseInt(fs.statSync(file).mtime * 1000); + let size = parseInt(fs.statSync(file).size); + + + let json = await API.createAttachmentItem("imported_file", { + contentType: contentType, + charset: charset + }, false, this, 'jsonData'); + let itemKey = json.key; + + // Get upload authorization + let response = await API.userPost( + config.userID, + "items/" + itemKey + "/file", + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + + json = API.getJSONFromResponse(response); + + toDelete.push(hash); + + // Upload to S3 + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": json.contentType + } + ); + Helpers.assert201(response); + + // Register upload + response = await API.userPost( + config.userID, + "items/" + itemKey + "/file", + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + let newVersion = response.headers['last-modified-version'][0]; + + filename = "test.pdf"; + mtime = Date.now(); + hash = Helpers.md5(Helpers.uniqueID()); + + response = await API.userPatch( + config.userID, + "items/" + itemKey, + JSON.stringify({ + filename: filename, + mtime: mtime, + md5: hash, + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": newVersion + } + ); + Helpers.assert204(response); + + response = await API.userGet( + config.userID, + "items/" + itemKey + "/file" + ); + Helpers.assert404(response); + }); + + it('test_add_embedded_image_attachment', async function () { + await API.userClear(config.userID); + + const noteKey = await API.createNoteItem("", null, this, 'key'); + + const file = "work/file"; + const fileContents = Helpers.getRandomUnicodeString(); + const contentType = "image/png"; + fs.writeFileSync(file, fileContents); + const hash = Helpers.md5(fileContents); + const filename = "image.png"; + const mtime = fs.statSync(file).mtime * 1000; + const size = fs.statSync(file).size; + + let json = await API.createAttachmentItem("embedded_image", { + parentItem: noteKey, + contentType: contentType + }, false, this, 'jsonData'); + + const key = json.key; + const originalVersion = json.version; + + // Get authorization + let response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size + }), + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + toDelete.push(hash); + + // Upload to S3 + response = await HTTP.post( + json.url, + `${json.prefix}${fileContents}${json.suffix}`, + { + + "Content-Type": `${json.contentType}` + } + + ); + Helpers.assert201(response); + + // Register upload + response = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${json.uploadKey}`, + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + + } + ); + Helpers.assert204(response); + const newVersion = response.headers['last-modified-version']; + assert.isAbove(parseInt(newVersion), parseInt(originalVersion)); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${key}` + ); + json = API.getJSONFromResponse(response).data; + assert.equal(hash, json.md5); + assert.equal(mtime, json.mtime); + assert.equal(filename, json.filename); + assert.equal(contentType, json.contentType); + assert.notProperty(json, 'charset'); + }); + + it('testAddFileClientV5Zip', async function () { + await API.userClear(config.userID); + + const fileContents = Helpers.getRandomUnicodeString(); + const contentType = "text/html"; + const charset = "utf-8"; + const filename = "file.html"; + const mtime = Date.now() / 1000 | 0; + const hash = Helpers.md5(fileContents); + + // Get last storage sync + let response = await API.userGet(config.userID, "laststoragesync"); + Helpers.assert404(response); + + let json = await API.createItem("book", false, this, 'jsonData'); + let key = json.key; + + json = await API.createAttachmentItem("imported_url", { + contentType, + charset + }, key, this, 'jsonData'); + key = json.key; + + const zipData = await generateZip(filename, Helpers.getRandomUnicodeString(), `work/${key}.zip`); + + const zipFilename = `${key}.zip`; + + + // + // Get upload authorization + // + response = await API.userPost(config.userID, `items/${key}/file`, Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: zipData.zipSize, + zipMD5: zipData.hash, + zipFilename: zipFilename + }), { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + toDelete.push(zipData.hash); + + // Upload to S3 + response = await HTTP.post(json.url, json.prefix + zipData.fileContent + json.suffix, { + + "Content-Type": json.contentType + + }); + Helpers.assert201(response); + + // + // Register upload + // + + // If-Match with file hash shouldn't match unregistered file + response = await API.userPost(config.userID, `items/${key}/file`, `upload=${json.uploadKey}`, { + + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + + }); + Helpers.assert412(response); + + // If-Match with ZIP hash shouldn't match unregistered file + response = await API.userPost(config.userID, `items/${key}/file`, `upload=${json.uploadKey}`, { + + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": zipData.hash + } + ); + Helpers.assert412(response); + + response = await API.userPost(config.userID, `items/${key}/file`, `upload=${json.uploadKey}`, { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + const newVersion = response.headers["last-modified-version"]; + + // Verify attachment item metadata + response = await API.userGet(config.userID, `items/${key}`); + json = API.getJSONFromResponse(response).data; + assert.equal(hash, json.md5); + assert.equal(mtime, json.mtime); + assert.equal(filename, json.filename); + assert.equal(contentType, json.contentType); + assert.equal(charset, json.charset); + + response = await API.userGet(config.userID, "laststoragesync"); + Helpers.assert200(response); + Helpers.assertRegExp(/^[0-9]{10}$/, response.data); + + // File exists + response = await API.userPost(config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime + 1000, + filename: filename, + filesize: zipData.zipSize, + zip: 1, + zipMD5: zipData.hash, + zipFilename: zipFilename + }), { + + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + assert.property(json, "exists"); + const version = response.headers["last-modified-version"]; + assert.isAbove(parseInt(version), parseInt(newVersion)); + }); + + it('test_updating_compressed_attachment_hash_should_clear_associated_storage_file', async function () { + // Create initial file + let fileContents = Helpers.getRandomUnicodeString(); + let contentType = "text/html"; + let charset = "utf-8"; + let filename = "file.html"; + let mtime = Math.floor(Date.now() / 1000); + let hash = Helpers.md5(fileContents); + + let json = await API.createAttachmentItem("imported_file", { + contentType: contentType, + charset: charset + }, false, this, 'jsonData'); + let itemKey = json.key; + + let file = "work/" + itemKey + ".zip"; + let zipFilename = "work/" + itemKey + ".zip"; + + // Create initial ZIP file + const zipData = await generateZip(file, fileContents, zipFilename); + let zipHash = zipData.hash; + let zipSize = zipData.zipSize; + let zipFileContents = zipData.fileContent; + + // Get upload authorization + let response = await API.userPost( + config.userID, + "items/" + itemKey + "/file", + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: zipSize, + zipMD5: zipHash, + zipFilename: zipFilename + }), + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + toDelete.push(zipHash); + + // Upload to S3 + response = await HTTP.post( + json.url, + json.prefix + zipFileContents + json.suffix, + { + + "Content-Type": json.contentType + } + + ); + Helpers.assert201(response); + + // Register upload + response = await API.userPost( + config.userID, + "items/" + itemKey + "/file", + "upload=" + json.uploadKey, + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + + ); + Helpers.assert204(response); + let newVersion = response.headers['last-modified-version']; + + // Set new attachment file info + hash = Helpers.md5(Helpers.uniqueID()); + mtime = Date.now(); + zipHash = Helpers.md5(Helpers.uniqueID()); + zipSize += 1; + response = await API.userPatch( + config.userID, + "items/" + itemKey, + JSON.stringify({ + md5: hash, + mtime: mtime, + filename: filename + }), + { + + "Content-Type": "application/json", + "If-Unmodified-Since-Version": newVersion + } + + ); + Helpers.assert204(response); + + response = await API.userGet( + config.userID, + "items/" + itemKey + "/file" + ); + Helpers.assert404(response); + }); + + it('test_replace_file_with_new_file', async function () { + await API.userClear(config.userID); + + const file = "work/file"; + const fileContents = Helpers.getRandomUnicodeString(); + const contentType = "text/html"; + const charset = "utf-8"; + fs.writeFileSync(file, fileContents); + const hash = Helpers.md5File(file); + const filename = "test_" + fileContents; + const mtime = fs.statSync(file).mtime * 1000; + const size = fs.statSync(file).size; + + const json = await API.createAttachmentItem("imported_file", { + contentType: contentType, + charset: charset + }, false, this, 'jsonData'); + const key = json.key; + + // Get authorization + const response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + const data = JSON.parse(response.data); + + toDelete.push(hash); + + const s3FilePath + = data.prefix + fileContents + data.suffix; + // Upload to S3 + const s3response = await HTTP.post( + data.url, + s3FilePath, + { + "Content-Type": data.contentType + } + ); + Helpers.assert201(s3response); + + // Successful registration + const success = await API.userPost( + config.userID, + `items/${key}/file`, + "upload=" + data.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(success); + + // Verify attachment item metadata + const metaDataResponse = await API.userGet( + config.userID, + `items/${key}` + ); + const metaDataJson = API.getJSONFromResponse(metaDataResponse); + Helpers.assertEquals(hash, metaDataJson.data.md5); + Helpers.assertEquals(mtime, metaDataJson.data.mtime); + Helpers.assertEquals(filename, metaDataJson.data.filename); + Helpers.assertEquals(contentType, metaDataJson.data.contentType); + Helpers.assertEquals(charset, metaDataJson.data.charset); + + const newFileContents + = Helpers.getRandomUnicodeString() + Helpers.getRandomUnicodeString(); + fs.writeFileSync(file, newFileContents); + const newHash = Helpers.md5File(file); + const newFilename = "test_" + newFileContents; + const newMtime = fs.statSync(file).mtime * 1000; + const newSize = fs.statSync(file).size; + + // Update file + const updateResponse + = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: newHash, + mtime: newMtime, + filename: newFilename, + filesize: newSize + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert200(updateResponse); + const updateData = JSON.parse(updateResponse.data); + + toDelete.push(newHash); + // Upload to S3 + const updateS3response = await HTTP.post( + updateData.url, + `${updateData.prefix}${newFileContents}${updateData.suffix}`, + { + "Content-Type": updateData.contentType + } + ); + Helpers.assert201(updateS3response); + + // Successful registration + const succeeded = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${updateData.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert204(succeeded); + + // Verify new attachment item metadata + const updatedMetaDataResponse = await API.userGet( + config.userID, + `items/${key}` + ); + const updatedMetaDataJson = API.getJSONFromResponse(updatedMetaDataResponse); + Helpers.assertEquals(newHash, updatedMetaDataJson.data.md5); + Helpers.assertEquals(newMtime, updatedMetaDataJson.data.mtime); + Helpers.assertEquals(newFilename, updatedMetaDataJson.data.filename); + Helpers.assertEquals( + contentType, + updatedMetaDataJson.data.contentType + ); + Helpers.assertEquals(charset, updatedMetaDataJson.data.charset); + }); + + it('testClientV5ShouldReturn404GettingAuthorizationForMissingFile', async function () { + let params = { + md5: Helpers.md5('qzpqBjLddCc6UhfX'), + mtime: 1477002989206, + filename: 'test.pdf', + filesize: 12345, + }; + let headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'If-None-Match': '*', + }; + let response = await API.userPost( + config.userID, + 'items/UP24VFQR/file', + Helpers.implodeParams(params), + headers + ); + Helpers.assert404(response); + }); + + // TODO: Reject for keys not owned by user, even if public library + it('testLastStorageSyncNoAuthorization', async function () { + API.useAPIKey(false); + let response = await API.userGet( + config.userID, + "laststoragesync", + { "Content-Type": "application/json" } + ); + Helpers.assert401(response); + }); + + it('testAddFileClientV5', async function () { + await API.userClear(config.userID); + + const file = "work/file"; + const fileContents = Helpers.getRandomUnicodeString(); + const contentType = "text/html"; + const charset = "utf-8"; + fs.writeFileSync(file, fileContents); + const hash = crypto.createHash('md5').update(fileContents).digest("hex"); + const filename = "test_" + fileContents; + const mtime = fs.statSync(file).mtime * 1000; + const size = fs.statSync(file).size; + + // Get last storage sync + let response = await API.userGet( + config.userID, + "laststoragesync" + ); + Helpers.assert404(response); + + const json = await API.createAttachmentItem("imported_file", { + contentType: contentType, + charset: charset + }, false, this, 'jsonData'); + const key = json.key; + const originalVersion = json.version; + + // File shouldn't exist + response = await API.userGet( + config.userID, + `items/${key}/file` + ); + Helpers.assert404(response); + + // + // Get upload authorization + // + + // Require If-Match/If-None-Match + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded" + } + ); + Helpers.assert428(response, "If-Match/If-None-Match header not provided"); + + // Get authorization + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + const uploadJSON = API.getJSONFromResponse(response); + + toDelete.push(hash); + + // + // Upload to S3 + // + let s3Headers = { + "Content-Type": uploadJSON.contentType + }; + response = await HTTP.post( + uploadJSON.url, + uploadJSON.prefix + fileContents + uploadJSON.suffix, + s3Headers + ); + Helpers.assert201(response); + + // + // Register upload + // + + // Require If-Match/If-None-Match + response = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${uploadJSON.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded" + } + ); + Helpers.assert428(response, "If-Match/If-None-Match header not provided"); + + // Invalid upload key + response = await API.userPost( + config.userID, + `items/${key}/file`, + "upload=invalidUploadKey", + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert400(response); + + // If-Match shouldn't match unregistered file + response = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${uploadJSON.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert412(response); + assert.notOk(response.headers['last-modified-version']); + + // Successful registration + response = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${uploadJSON.uploadKey}`, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + const newVersion = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(newVersion), parseInt(originalVersion)); + + // Verify attachment item metadata + response = await API.userGet( + config.userID, + `items/${key}` + ); + const jsonResp = API.getJSONFromResponse(response).data; + assert.equal(hash, jsonResp.md5); + assert.equal(mtime, jsonResp.mtime); + assert.equal(filename, jsonResp.filename); + assert.equal(contentType, jsonResp.contentType); + assert.equal(charset, jsonResp.charset); + + response = await API.userGet( + config.userID, + "laststoragesync" + ); + Helpers.assert200(response); + Helpers.assertRegExp(/^[0-9]{10}$/, response.data); + + // + // Update file + // + + // Conflict for If-None-Match when file exists + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime + 1000, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert412(response, "If-None-Match: * set but file exists"); + assert.notEqual(response.headers['last-modified-version'][0], null); + + // Conflict for If-Match when existing file differs + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime + 1000, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": Helpers.md5("invalid") + } + ); + Helpers.assert412(response, "ETag does not match current version of file"); + assert.notEqual(response.headers['last-modified-version'][0], null); + + // Error if wrong file size given for existing file + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime + 1000, + filename: filename, + filesize: size - 1 + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert400(response, "Specified file size incorrect for known file"); + + // File exists + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime + 1000, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert200(response); + let existsJSON = API.getJSONFromResponse(response); + assert.property(existsJSON, "exists"); + let version = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(version), parseInt(newVersion)); + + // File exists with different filename + response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime + 1000, + filename: filename + '等', + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-Match": hash + } + ); + Helpers.assert200(response); + existsJSON = API.getJSONFromResponse(response); + assert.property(existsJSON, "exists"); + version = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(version), parseInt(newVersion)); + }); + + it('test_should_include_best_attachment_link_on_parent_for_imported_file', async function () { + let json = await API.createItem("book", false, this, 'json'); + assert.equal(0, json.meta.numChildren); + let parentKey = json.key; + + json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'json'); + let attachmentKey = json.key; + let version = json.version; + + let filename = "test.pdf"; + let mtime = Date.now(); + let fileContents = fs.readFileSync("data/test.pdf"); + let size = Buffer.from(fileContents.toString()).byteLength; + let md5 = Helpers.md5(Buffer.from(fileContents.toString())); + + // Create attachment item + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([ + { + key: attachmentKey, + contentType: "application/pdf", + } + ]), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assert200ForObject(response); + + // 'attachment' link shouldn't appear if no uploaded file + response = await API.userGet( + config.userID, + "items/" + parentKey + ); + json = API.getJSONFromResponse(response); + assert.notProperty(json.links, 'attachment'); + + // Get upload authorization + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + Helpers.implodeParams({ + md5: md5, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + // If file doesn't exist on S3, upload + if (!json.exists) { + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": json.contentType + } + ); + Helpers.assert201(response); + + // Post-upload file registration + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + } + toDelete.push(md5); + + // 'attachment' link should now appear + response = await API.userGet( + config.userID, + "items/" + parentKey + ); + json = API.getJSONFromResponse(response); + assert.property(json.links, 'attachment'); + assert.property(json.links.attachment, 'href'); + assert.equal('application/json', json.links.attachment.type); + assert.equal('application/pdf', json.links.attachment.attachmentType); + assert.equal(size, json.links.attachment.attachmentSize); + }); + + it('testAddFileClientV4', async function () { + await API.userClear(config.userID); + + const fileContentType = 'text/html'; + const fileCharset = 'utf-8'; + + const auth = { + username: config.username, + password: config.password, + }; + + // Get last storage sync + let response = await API.userGet( + config.userID, + 'laststoragesync?auth=1', + {}, + auth + ); + Helpers.assert404(response); + + const json = await API.createAttachmentItem( + 'imported_file', + [], + false, + this, + 'jsonData' + ); + let originalVersion = json.version; + json.contentType = fileContentType; + json.charset = fileCharset; + + response = await API.userPut( + config.userID, + `items/${json.key}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assert204(response); + originalVersion = response.headers['last-modified-version'][0]; + + // Get file info + response = await API.userGet( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1&info=1`, + {}, + auth + ); + Helpers.assert404(response); + + const file = 'work/file'; + const fileContents = Helpers.getRandomUnicodeString(); + fs.writeFileSync(file, fileContents); + const hash = crypto.createHash('md5').update(fileContents).digest('hex'); + const filename = `test_${fileContents}`; + const mtime = parseInt(fs.statSync(file).mtimeMs); + const size = parseInt(fs.statSync(file).size); + + // Get upload authorization + response = await API.userPost( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: size, + mtime, + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert200(response); + Helpers.assertContentType(response, 'application/xml'); + const xml = API.getXMLFromResponse(response); + const xmlParams = xml.getElementsByTagName('params')[0]; + const urlComponent = xml.getElementsByTagName('url')[0]; + const keyComponent = xml.getElementsByTagName('key')[0]; + toDelete.push(hash); + + const boundary = `---------------------------${Helpers.uniqueID()}`; + let postData = ''; + for (let child of xmlParams.children) { + const key = child.tagName; + const val = child.innerHTML; + postData += `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${val}\r\n`; + } + postData += `--${boundary}\r\nContent-Disposition: form-data; name="file"\r\n\r\n${fileContents}\r\n`; + postData += `--${boundary}--`; + + // Upload to S3 + response = await HTTP.post(urlComponent.innerHTML, postData, { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }); + Helpers.assert201(response); + + // + // Register upload + // + + // Invalid upload key + response = await API.userPost( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1`, + `update=invalidUploadKey&mtime=${mtime}`, + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert400(response); + + // No mtime + response = await API.userPost( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1`, + `update=${xml.key}`, + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert500(response); + + response = await API.userPost( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1`, + `update=${keyComponent.innerHTML}&mtime=${mtime}`, + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert204(response); + + // Verify attachment item metadata + response = await API.userGet(config.userID, `items/${json.key}`); + const { data } = API.getJSONFromResponse(response); + // Make sure attachment item version hasn't changed (or else the client + // will get a conflict when it tries to update the metadata) + assert.equal(originalVersion, data.version); + assert.equal(hash, data.md5); + assert.equal(filename, data.filename); + assert.equal(mtime, data.mtime); + + response = await API.userGet( + config.userID, + 'laststoragesync?auth=1', + {}, + { + username: config.username, + password: config.password, + } + ); + Helpers.assert200(response); + const newMtime = response.data; + assert.match(newMtime, /^[0-9]{10}$/); + + // File exists + response = await API.userPost( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1`, + Helpers.implodeParams({ + md5: hash, + filename, + filesize: size, + mtime: newMtime + 1000, + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert200(response); + Helpers.assertContentType(response, 'application/xml'); + assert.equal('', response.data); + + // File exists with different filename + response = await API.userPost( + config.userID, + `items/${json.key}/file?auth=1&iskey=1&version=1`, + Helpers.implodeParams({ + md5: hash, + filename: `${filename}等`, // Unicode 1.1 character, to test signature generation + filesize: size, + mtime: newMtime + 1000, + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth + ); + Helpers.assert200(response); + Helpers.assertContentType(response, 'application/xml'); + assert.equal('', response.data); + + // Make sure attachment version still hasn't changed + response = await API.userGet(config.userID, `items/${json.key}`); + const { version } = API.getJSONFromResponse(response).data; + assert.equal(originalVersion, version); + }); +}); diff --git a/tests/remote_js/test/3/fullTextTest.mjs b/tests/remote_js/test/3/fullTextTest.mjs new file mode 100644 index 00000000..83213216 --- /dev/null +++ b/tests/remote_js/test/3/fullTextTest.mjs @@ -0,0 +1,459 @@ +import chai from 'chai'; +const assert = chai.assert; +import config from 'config'; +import API from '../../api3.js'; +import Helpers from '../../helpers3.js'; +import shared from "../shared.js"; +import { s3 } from "../../full-text-indexer/index.mjs"; +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; + +describe('FullTextTests', function () { + this.timeout(0); + const s3Client = new S3Client({ region: "us-east-1" }); + + before(async function () { + await shared.API3Before(); + }); + + after(async function () { + await shared.API3After(); + }); + + this.beforeEach(async function () { + API.useAPIKey(config.apiKey); + }); + + it('testContentAnonymous', async function () { + API.useAPIKey(false); + const response = await API.userGet( + config.userID, + 'items/AAAAAAAA/fulltext', + { 'Content-Type': 'application/json' } + ); + Helpers.assert403(response); + }); + + it('testModifyAttachmentWithFulltext', async function () { + let key = await API.createItem("book", false, this, 'key'); + let json = await API.createAttachmentItem("imported_url", [], key, this, 'jsonData'); + let attachmentKey = json.key; + let content = "Here is some full-text content"; + let pages = 50; + + // Store content + let response = await API.userPut( + config.userID, + "items/" + attachmentKey + "/fulltext", + JSON.stringify({ + content: content, + indexedPages: pages, + totalPages: pages + }), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + + json.title = "This is a new attachment title"; + json.contentType = 'text/plain'; + + // Modify attachment item + response = await API.userPut( + config.userID, + "items/" + attachmentKey, + JSON.stringify(json), + { "If-Unmodified-Since-Version": json.version } + ); + Helpers.assert204(response); + }); + + it('testSetItemContentMultiple', async function () { + let key = await API.createItem("book", false, this, 'key'); + let attachmentKey1 = await API.createAttachmentItem("imported_url", [], key, this, 'key'); + let attachmentKey2 = await API.createAttachmentItem("imported_url", [], key, this, 'key'); + + let libraryVersion = await API.getLibraryVersion(); + + let json = [ + { + key: attachmentKey1, + content: "Here is some full-text content", + indexedPages: 50, + totalPages: 50, + invalidParam: "shouldBeIgnored" + }, + { + content: "This is missing a key and should be skipped", + indexedPages: 20, + totalPages: 40 + }, + { + key: attachmentKey2, + content: "Here is some more full-text content", + indexedPages: 20, + totalPages: 40 + } + ]; + + // No Content-Type + let response = await API.userPost( + config.userID, + "fulltext", + JSON.stringify(json), + { + "If-Unmodified-Since-Version": libraryVersion + } + ); + Helpers.assert400(response, "Content-Type must be application/json"); + + // No If-Unmodified-Since-Version + response = await API.userPost( + config.userID, + "fulltext", + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert428(response, "If-Unmodified-Since-Version not provided"); + + // Store content + response = await API.userPost( + config.userID, + "fulltext", + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": libraryVersion + } + ); + + Helpers.assert200(response); + Helpers.assert200ForObject(response, { index: 0 }); + Helpers.assert400ForObject(response, { index: 1 }); + Helpers.assert200ForObject(response, { index: 2 }); + let newLibraryVersion = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(newLibraryVersion), parseInt(libraryVersion)); + libraryVersion = newLibraryVersion; + + let originalJSON = json; + + // Retrieve content + response = await API.userGet( + config.userID, + "items/" + attachmentKey1 + "/fulltext" + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + Helpers.assertEquals(originalJSON[0].content, json.content); + Helpers.assertEquals(originalJSON[0].indexedPages, json.indexedPages); + Helpers.assertEquals(originalJSON[0].totalPages, json.totalPages); + assert.notProperty(json, "indexedChars"); + assert.notProperty(json, "invalidParam"); + Helpers.assertEquals(libraryVersion, response.headers['last-modified-version'][0]); + + response = await API.userGet( + config.userID, + "items/" + attachmentKey2 + "/fulltext" + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + Helpers.assertEquals(originalJSON[2].content, json.content); + Helpers.assertEquals(originalJSON[2].indexedPages, json.indexedPages); + Helpers.assertEquals(originalJSON[2].totalPages, json.totalPages); + assert.notProperty(json, "indexedChars"); + assert.notProperty(json, "invalidParam"); + Helpers.assertEquals(libraryVersion, response.headers['last-modified-version'][0]); + }); + + it('testSetItemContent', async function () { + let response = await API.createItem("book", false, this, 'key'); + let attachmentKey = await API.createAttachmentItem("imported_url", [], response, this, 'key'); + + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext" + ); + Helpers.assert404(response); + assert.notOk(response.headers['last-modified-version']); + + let libraryVersion = await API.getLibraryVersion(); + + let content = "Here is some full-text content"; + let pages = 50; + + // No Content-Type + response = await API.userPut( + config.userID, + "items/" + attachmentKey + "/fulltext", + content + ); + Helpers.assert400(response, "Content-Type must be application/json"); + + // Store content + response = await API.userPut( + config.userID, + "items/" + attachmentKey + "/fulltext", + JSON.stringify({ + content: content, + indexedPages: pages, + totalPages: pages, + invalidParam: "shouldBeIgnored" + }), + { "Content-Type": "application/json" } + ); + + Helpers.assert204(response); + let contentVersion = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(contentVersion), parseInt(libraryVersion)); + + // Retrieve it + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext" + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + let json = JSON.parse(await response.data); + Helpers.assertEquals(content, json.content); + assert.property(json, 'indexedPages'); + assert.property(json, 'totalPages'); + Helpers.assertEquals(pages, json.indexedPages); + Helpers.assertEquals(pages, json.totalPages); + assert.notProperty(json, "indexedChars"); + assert.notProperty(json, "invalidParam"); + Helpers.assertEquals(contentVersion, response.headers['last-modified-version'][0]); + }); + + // Requires ES + it('testSearchItemContent', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + let parentKey = await API.createItem("book", { collections: [collectionKey] }, this, 'key'); + let json = await API.createAttachmentItem("imported_url", [], parentKey, this, 'jsonData'); + let attachmentKey = json.key; + + let response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext" + ); + Helpers.assert404(response); + + let content = "Here is some unique full-text content"; + let pages = 50; + + // Store content + response = await API.userPut( + config.userID, + "items/" + attachmentKey + "/fulltext", + JSON.stringify({ + content: content, + indexedPages: pages, + totalPages: pages + }), + { "Content-Type": "application/json" } + ); + + Helpers.assert204(response); + + // Local fake-invoke of lambda function that indexes pdf + if (config.isLocalRun) { + const s3Result = await s3Client.send(new GetObjectCommand({ Bucket: config.s3Bucket, Key: `${config.userID}/${attachmentKey}` })); + + const event = { + eventName: "ObjectCreated", + s3: { + bucket: { + name: config.s3Bucket + }, + object: { + key: `${config.userID}/${attachmentKey}`, + eTag: s3Result.ETag.slice(1, -1) + } + }, + + }; + await s3({ Records: [event] }); + } + + // Wait for indexing via Lambda + await new Promise(resolve => setTimeout(resolve, 6000)); + + // Search for nonexistent word + response = await API.userGet( + config.userID, + "items?q=nothing&qmode=everything&format=keys" + ); + Helpers.assert200(response); + Helpers.assertEquals("", response.data.trim()); + + // Search for a word + response = await API.userGet( + config.userID, + "items?q=unique&qmode=everything&format=keys" + ); + Helpers.assert200(response); + Helpers.assertEquals(attachmentKey, response.data.trim()); + + // Search for a phrase + response = await API.userGet( + config.userID, + "items?q=unique%20full-text&qmode=everything&format=keys" + ); + Helpers.assert200(response); + Helpers.assertEquals(attachmentKey, response.data.trim()); + + // Search for a phrase in /top + response = await API.userGet( + config.userID, + "items/top?q=unique%20full-text&qmode=everything&format=keys" + ); + Helpers.assert200(response); + Helpers.assertEquals(parentKey, response.data.trim()); + + // Search for a phrase in a collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items?q=unique%20full-text&qmode=everything&format=keys" + ); + Helpers.assert200(response); + Helpers.assertEquals(attachmentKey, response.data.trim()); + + // Search for a phrase in a collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items/top?q=unique%20full-text&qmode=everything&format=keys" + ); + Helpers.assert200(response); + Helpers.assertEquals(parentKey, response.data.trim()); + }); + + it('testSinceContent', async function () { + await _testSinceContent('since'); + await _testSinceContent('newer'); + }); + + const _testSinceContent = async (param) => { + await API.userClear(config.userID); + + // Store content for one item + let key = await API.createItem("book", false, true, 'key'); + let json = await API.createAttachmentItem("imported_url", [], key, true, 'jsonData'); + let key1 = json.key; + + let content = "Here is some full-text content"; + + let response = await API.userPut( + config.userID, + `items/${key1}/fulltext`, + JSON.stringify([{ content: content }]), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + let contentVersion1 = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(contentVersion1), 0); + + // And another + key = await API.createItem("book", false, true, 'key'); + json = await API.createAttachmentItem("imported_url", [], key, true, 'jsonData'); + let key2 = json.key; + + response = await API.userPut( + config.userID, + `items/${key2}/fulltext`, + JSON.stringify({ content: content }), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + let contentVersion2 = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(contentVersion2), 0); + + // Get newer one + response = await API.userGet( + config.userID, + `fulltext?${param}=${contentVersion1}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + Helpers.assertEquals(contentVersion2, response.headers['last-modified-version'][0]); + json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json), 1); + assert.property(json, key2); + Helpers.assertEquals(contentVersion2, json[key2]); + + // Get both with since=0 + response = await API.userGet( + config.userID, + `fulltext?${param}=0` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = API.getJSONFromResponse(response); + assert.lengthOf(Object.keys(json), 2); + assert.property(json, key1); + Helpers.assertEquals(contentVersion1, json[key1]); + assert.property(json, key1); + Helpers.assertEquals(contentVersion2, json[key2]); + }; + + it('testDeleteItemContent', async function () { + let key = await API.createItem("book", false, this, 'key'); + let attachmentKey = await API.createAttachmentItem("imported_file", [], key, this, 'key'); + + let content = "Ыюм мютат дэбетиз конвынёры эю, ку мэль жкрипта трактатоз.\nПро ут чтэт эрепюят граэкйж, дуо нэ выро рыкючабо пырикюлёз."; + + // Store content + let response = await API.userPut( + config.userID, + "items/" + attachmentKey + "/fulltext", + JSON.stringify({ + content: content, + indexedPages: 50 + }), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + let contentVersion = response.headers['last-modified-version'][0]; + + // Retrieve it + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext" + ); + Helpers.assert200(response); + let json = JSON.parse(response.data); + Helpers.assertEquals(content, json.content); + Helpers.assertEquals(50, json.indexedPages); + + // Set to empty string + response = await API.userPut( + config.userID, + "items/" + attachmentKey + "/fulltext", + JSON.stringify({ + content: "" + }), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + assert.isAbove(parseInt(response.headers['last-modified-version'][0]), parseInt(contentVersion)); + + // Make sure it's gone + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext" + ); + Helpers.assert200(response); + json = JSON.parse(response.data); + Helpers.assertEquals("", json.content); + assert.notProperty(json, "indexedPages"); + }); + + it('testVersionsAnonymous', async function () { + API.useAPIKey(false); + const response = await API.userGet( + config.userID, + "fulltext" + ); + Helpers.assert403(response); + }); +}); diff --git a/tests/remote_js/test/3/generalTest.js b/tests/remote_js/test/3/generalTest.js new file mode 100644 index 00000000..8dcaf26c --- /dev/null +++ b/tests/remote_js/test/3/generalTest.js @@ -0,0 +1,64 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + + +describe('GeneralTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + it('testInvalidCharacters', async function () { + const data = { + title: "A" + String.fromCharCode(0) + "A", + creators: [ + { + creatorType: "author", + name: "B" + String.fromCharCode(1) + "B" + } + ], + tags: [ + { + tag: "C" + String.fromCharCode(2) + "C" + } + ] + }; + const json = await API.createItem("book", data, this, 'jsonData'); + assert.equal("AA", json.title); + assert.equal("BB", json.creators[0].name); + assert.equal("CC", json.tags[0].tag); + }); + + it('testZoteroWriteToken', async function () { + const json = await API.getItemTemplate('book'); + const token = Helpers.uniqueToken(); + + let response = await API.userPost( + config.userID, + `items`, + JSON.stringify([json]), + { 'Content-Type': 'application/json', 'Zotero-Write-Token': token } + ); + + Helpers.assertStatusCode(response, 200); + Helpers.assert200ForObject(response); + + response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ items: [json] }), + { 'Content-Type': 'application/json', 'Zotero-Write-Token': token } + ); + + Helpers.assertStatusCode(response, 412); + }); +}); diff --git a/tests/remote_js/test/3/groupTest.js b/tests/remote_js/test/3/groupTest.js new file mode 100644 index 00000000..2aceb6c7 --- /dev/null +++ b/tests/remote_js/test/3/groupTest.js @@ -0,0 +1,294 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { JSDOM } = require('jsdom'); +const { API3Before, API3After } = require("../shared.js"); + +describe('Tests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + + it('testDeleteGroup', async function () { + let groupID = await API.createGroup({ + owner: config.userID, + type: 'Private', + libraryReading: 'all', + }); + await API.groupCreateItem(groupID, 'book', false, this, 'key'); + await API.groupCreateItem(groupID, 'book', false, this, 'key'); + await API.groupCreateItem(groupID, 'book', false, this, 'key'); + await API.deleteGroup(groupID); + + const response = await API.groupGet(groupID, ''); + Helpers.assert404(response); + }); + + it('testUpdateMemberJSON', async function () { + let groupID = await API.createGroup({ + owner: config.userID, + type: 'Private', + libraryReading: 'all' + }); + + // Get group version + let response = await API.userGet(config.userID, `groups?format=versions&key=${config.apiKey}`); + Helpers.assert200(response); + let version = JSON.parse(response.data)[groupID]; + + response = await API.superPost(`groups/${groupID}/users`, '', { 'Content-Type': 'text/xml' }); + Helpers.assert200(response); + + // Group metadata version should have changed + response = await API.userGet(config.userID, `groups?format=versions&key=${config.apiKey}`); + Helpers.assert200(response); + let json = JSON.parse(response.data); + let newVersion = json[groupID]; + assert.notEqual(version, newVersion); + + // Check version header on individual group request + response = await API.groupGet(groupID, ''); + Helpers.assert200(response); + Helpers.assertEquals(newVersion, response.headers['last-modified-version'][0]); + + await API.deleteGroup(groupID); + }); + + /** + * Changing a group's metadata should change its version + */ + it('testUpdateMetadataAtom', async function () { + let response = await API.userGet( + config.userID, + `groups?fq=GroupType:PublicOpen&content=json&key=${config.apiKey}` + ); + Helpers.assert200(response); + + // Get group API URI and version + let xml = API.getXMLFromResponse(response); + + let groupID = await Helpers.xpathEval(xml, '//atom:entry/zapi:groupID'); + let urlComponent = await Helpers.xpathEval(xml, "//atom:entry/atom:link[@rel='self']", true, false); + let url = urlComponent.getAttribute('href'); + url = url.replace(config.apiURLPrefix, ''); + let version = JSON.parse(API.parseDataFromAtomEntry(xml).content).version; + + // Make sure format=versions returns the same version + response = await API.userGet( + config.userID, + `groups?format=versions&key=${config.apiKey}` + ); + Helpers.assert200(response); + let json = JSON.parse(response.data); + assert.equal(version, json[groupID]); + + // Update group metadata + json = JSON.parse(await Helpers.xpathEval(xml, "//atom:entry/atom:content")); + + const xmlDoc = new JSDOM(""); + const groupXML = xmlDoc.window.document.getElementsByTagName("group")[0]; + let name, description, urlField, newNode; + for (let [key, val] of Object.entries(json)) { + switch (key) { + case 'id': + case 'members': + continue; + + case 'name': + name = "My Test Group " + Math.random(); + groupXML.setAttribute('name', name); + break; + + case 'description': + description = "This is a test description " + Math.random(); + newNode = xmlDoc.window.document.createElement(key); + newNode.innerHTML = description; + groupXML.appendChild(newNode); + break; + + case 'url': + urlField = "http://example.com/" + Math.random(); + newNode = xmlDoc.window.document.createElement(key); + newNode.innerHTML = urlField; + groupXML.appendChild(newNode); + break; + + default: + groupXML.setAttributeNS(null, key, val); + } + } + const payload = groupXML.outerHTML; + response = await API.put( + url, + payload, + { "Content-Type": "text/xml" }, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + Helpers.assert200(response); + xml = API.getXMLFromResponse(response); + let group = await Helpers.xpathEval(xml, '//atom:entry/atom:content/zxfer:group', true, true); + Helpers.assertCount(1, group); + assert.equal(name, group[0].getAttribute('name')); + + response = await API.userGet( + config.userID, + `groups?format=versions&key=${config.apiKey}` + ); + Helpers.assert200(response); + json = JSON.parse(response.data); + let newVersion = json[groupID]; + assert.notEqual(version, newVersion); + + // Check version header on individual group request + response = await API.groupGet( + groupID, + `?content=json&key=${config.apiKey}` + ); + Helpers.assert200(response); + assert.equal(newVersion, response.headers['last-modified-version'][0]); + json = JSON.parse(API.getContentFromResponse(response)); + assert.equal(name, json.name); + assert.equal(description, json.description); + assert.equal(urlField, json.url); + }); + + /** + * Changing a group's metadata should change its version + */ + it('testUpdateMetadataJSON', async function () { + const response = await API.userGet( + config.userID, + "groups?fq=GroupType:PublicOpen" + ); + + Helpers.assert200(response); + + // Get group API URI and version + const json = API.getJSONFromResponse(response)[0]; + const groupID = json.id; + let url = json.links.self.href; + url = url.replace(config.apiURLPrefix, ''); + const version = json.version; + + // Make sure format=versions returns the same version + const response2 = await API.userGet( + config.userID, + "groups?format=versions&key=" + config.apiKey + ); + + Helpers.assert200(response2); + + Helpers.assertEquals(version, JSON.parse(response2.data)[groupID]); + + // Update group metadata + const xmlDoc = new JSDOM(""); + const groupXML = xmlDoc.window.document.getElementsByTagName("group")[0]; + let name, description, urlField, newNode; + for (const [key, val] of Object.entries(json.data)) { + switch (key) { + case 'id': + case 'version': + case 'members': + continue; + case 'name': { + name = "My Test Group " + Helpers.uniqueID(); + groupXML.setAttributeNS(null, key, name); + break; + } + case 'description': { + description = "This is a test description " + Helpers.uniqueID(); + newNode = xmlDoc.window.document.createElement(key); + newNode.innerHTML = description; + groupXML.appendChild(newNode); + break; + } + case 'url': { + urlField = "http://example.com/" + Helpers.uniqueID(); + newNode = xmlDoc.window.document.createElement(key); + newNode.innerHTML = urlField; + groupXML.appendChild(newNode); + break; + } + default: + groupXML.setAttributeNS(null, key, val); + } + } + + const response3 = await API.put( + url, + groupXML.outerHTML, + { "Content-Type": "text/xml" }, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + + Helpers.assert200(response3); + + const xmlResponse = API.getXMLFromResponse(response3); + const group = Helpers.xpathEval(xmlResponse, '//atom:entry/atom:content/zxfer:group', true, true); + + Helpers.assertCount(1, group); + Helpers.assertEquals(name, group[0].getAttribute('name')); + + const response4 = await API.userGet( + config.userID, + "groups?format=versions&key=" + config.apiKey + ); + + Helpers.assert200(response4); + + const json2 = JSON.parse(response4.data); + const newVersion = json2[groupID]; + + assert.notEqual(version, newVersion); + + // Check version header on individual group request + const response5 = await API.groupGet( + groupID, + "" + ); + + Helpers.assert200(response5); + Helpers.assertEquals(newVersion, response5.headers['last-modified-version'][0]); + const json3 = API.getJSONFromResponse(response5).data; + + Helpers.assertEquals(name, json3.name); + Helpers.assertEquals(description, json3.description); + Helpers.assertEquals(urlField, json3.url); + }); + + it('test_group_should_not_appear_in_search_until_first_populated', async function () { + const name = Helpers.uniqueID(14); + const groupID = await API.createGroup({ + owner: config.userID, + type: 'PublicClosed', + name, + libraryReading: 'all' + }); + + // Group shouldn't show up if it's never had items + let response = await API.superGet(`groups?q=${name}`); + Helpers.assertNumResults(response, 0); + + await API.groupCreateItem(groupID, 'book', false, this); + + response = await API.superGet(`groups?q=${name}`); + Helpers.assertNumResults(response, 1); + + await API.deleteGroup(groupID); + }); +}); diff --git a/tests/remote_js/test/3/itemTest.js b/tests/remote_js/test/3/itemTest.js new file mode 100644 index 00000000..fbade9b9 --- /dev/null +++ b/tests/remote_js/test/3/itemTest.js @@ -0,0 +1,2852 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After, resetGroups } = require("../shared.js"); + +describe('ItemsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + await resetGroups(); + }); + + after(async function () { + await API3After(); + }); + + this.beforeEach(async function () { + await API.userClear(config.userID); + await API.groupClear(config.ownedPrivateGroupID); + API.useAPIKey(config.apiKey); + }); + + const testNewEmptyBookItem = async () => { + let json = await API.createItem("book", false, true); + json = json.successful[0].data; + assert.equal(json.itemType, "book"); + assert.equal(json.title, ""); + assert.equal(json.date, ""); + assert.equal(json.place, ""); + return json; + }; + + it('testNewEmptyBookItemMultiple', async function () { + let json = await API.getItemTemplate("book"); + + const data = []; + json.title = "A"; + data.push(json); + const json2 = Object.assign({}, json); + json2.title = "B"; + data.push(json2); + const json3 = Object.assign({}, json); + json3.title = "C"; + json3.numPages = 200; + data.push(json3); + + const response = await API.postItems(data); + Helpers.assertStatusCode(response, 200); + let libraryVersion = parseInt(response.headers['last-modified-version'][0]); + json = API.getJSONFromResponse(response); + Helpers.assertCount(3, json.successful); + Helpers.assertCount(3, json.success); + + for (let i = 0; i < 3; i++) { + assert.equal(json.successful[i].key, json.successful[i].data.key); + assert.equal(libraryVersion, json.successful[i].version); + assert.equal(libraryVersion, json.successful[i].data.version); + assert.equal(data[i].title, json.successful[i].data.title); + } + + assert.equal(data[2].numPages, json.successful[2].data.numPages); + + json = await API.getItem(Object.keys(json.success).map(k => json.success[k]), this, 'json'); + assert.equal(json[0].data.title, "A"); + assert.equal(json[1].data.title, "B"); + assert.equal(json[2].data.title, "C"); + }); + + it('testEditBookItem', async function () { + const newBookItem = await testNewEmptyBookItem(); + const key = newBookItem.key; + const version = newBookItem.version; + + const newTitle = 'New Title'; + const numPages = 100; + const creatorType = 'author'; + const firstName = 'Firstname'; + const lastName = 'Lastname'; + + newBookItem.title = newTitle; + newBookItem.numPages = numPages; + newBookItem.creators.push({ + creatorType: creatorType, + firstName: firstName, + lastName: lastName + }); + + const response = await API.userPut( + config.userID, + `items/${key}`, + JSON.stringify(newBookItem), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': version + } + ); + Helpers.assertStatusCode(response, 204); + + let json = (await API.getItem(key, true, 'json')).data; + + assert.equal(newTitle, json.title); + assert.equal(numPages, json.numPages); + assert.equal(creatorType, json.creators[0].creatorType); + assert.equal(firstName, json.creators[0].firstName); + assert.equal(lastName, json.creators[0].lastName); + }); + + it('testDateModified', async function () { + const objectType = 'item'; + const objectTypePlural = API.getPluralObjectType(objectType); + // In case this is ever extended to other objects + let json; + let itemData; + switch (objectType) { + case 'item': + itemData = { + title: "Test" + }; + json = await API.createItem("videoRecording", itemData, this, 'jsonData'); + break; + } + + const objectKey = json.key; + const dateModified1 = json.dateModified; + + // Make sure we're in the next second + await new Promise(resolve => setTimeout(resolve, 1000)); + + // + // If no explicit dateModified, use current timestamp + // + json.title = 'Test 2'; + delete json.dateModified; + let response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + switch (objectType) { + case 'item': + json = (await API.getItem(objectKey, true, 'json')).data; + break; + } + + const dateModified2 = json.dateModified; + assert.notEqual(dateModified1, dateModified2); + + // Make sure we're in the next second + await new Promise(resolve => setTimeout(resolve, 1000)); + + // + // If existing dateModified, use current timestamp + // + json.title = 'Test 3'; + json.dateModified = dateModified2.replace(/T|Z/g, " ").trim(); + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + switch (objectType) { + case 'item': + json = (await API.getItem(objectKey, true, 'json')).data; + break; + } + + const dateModified3 = json.dateModified; + assert.notEqual(dateModified2, dateModified3); + + // + // If explicit dateModified, use that + // + const newDateModified = "2013-03-03T21:33:53Z"; + json.title = 'Test 4'; + json.dateModified = newDateModified; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + + switch (objectType) { + case 'item': + json = (await API.getItem(objectKey, true, 'json')).data; + break; + } + const dateModified4 = json.dateModified; + assert.equal(newDateModified, dateModified4); + }); + + it('testDateAccessedInvalid', async function () { + const date = 'February 1, 2014'; + const response = await API.createItem("book", { accessDate: date }, true, 'response'); + // Invalid dates should be ignored + Helpers.assert400ForObject(response, { message: "'accessDate' must be in ISO 8601 or UTC 'YYYY-MM-DD[ hh:mm:ss]' format or 'CURRENT_TIMESTAMP' (February 1, 2014)" }); + }); + + it('testChangeItemType', async function () { + const json = await API.getItemTemplate("book"); + json.title = "Foo"; + json.numPages = 100; + + const response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + + const key = API.getFirstSuccessKeyFromResponse(response); + const json1 = (await API.getItem(key, true, 'json')).data; + const version = json1.version; + + const json2 = await API.getItemTemplate("bookSection"); + + Object.entries(json2).forEach(([field, _]) => { + if (field !== "itemType" && json1[field]) { + json2[field] = json1[field]; + } + }); + + const response2 = await API.userPut( + config.userID, + "items/" + key, + JSON.stringify(json2), + { "Content-Type": "application/json", "If-Unmodified-Since-Version": version } + ); + + Helpers.assertStatusCode(response2, 204); + + const json3 = (await API.getItem(key, true, 'json')).data; + assert.equal(json3.itemType, "bookSection"); + assert.equal(json3.title, "Foo"); + assert.notProperty(json3, "numPages"); + }); + + it('testPatchItem', async function () { + const itemData = { + title: "Test" + }; + const json = await API.createItem("book", itemData, this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + + const patch = async (itemKey, itemVersion, itemData, newData) => { + for (const field in newData) { + itemData[field] = newData[field]; + } + const response = await API.userPatch( + config.userID, + "items/" + itemKey + "?key=" + config.apiKey, + JSON.stringify(newData), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion + } + ); + Helpers.assertStatusCode(response, 204); + const json = (await API.getItem(itemKey, true, 'json')).data; + + for (const field in itemData) { + assert.deepEqual(itemData[field], json[field]); + } + const headerVersion = parseInt(response.headers["last-modified-version"][0]); + assert.isAbove(headerVersion, itemVersion); + assert.equal(json.version, headerVersion); + + return headerVersion; + }; + + let newData = { + date: "2013" + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + title: "" + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + tags: [ + { tag: "Foo" } + ] + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + tags: [] + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + const key = await API.createCollection('Test', false, this, 'key'); + newData = { + collections: [key] + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + collections: [] + }; + await patch(itemKey, itemVersion, itemData, newData); + }); + + it('testPatchItems', async function () { + const itemData = { + title: "Test" + }; + const json = await API.createItem("book", itemData, this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + + const patch = async (itemKey, itemVersion, itemData, newData) => { + for (const field in newData) { + itemData[field] = newData[field]; + } + newData.key = itemKey; + newData.version = itemVersion; + + const response = await API.userPost( + config.userID, + "items", + JSON.stringify([newData]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert200ForObject(response); + const json = (await API.getItem(itemKey, true, 'json')).data; + + for (const field in itemData) { + assert.deepEqual(itemData[field], json[field]); + } + const headerVersion = parseInt(response.headers["last-modified-version"][0]); + assert.isAbove(headerVersion, itemVersion); + assert.equal(json.version, headerVersion); + + return headerVersion; + }; + + let newData = { + date: "2013" + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + title: "" + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + tags: [ + { tag: "Foo" } + ] + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + tags: [] + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + const key = await API.createCollection('Test', false, this, 'key'); + newData = { + collections: [key] + }; + itemVersion = await patch(itemKey, itemVersion, itemData, newData); + + newData = { + collections: [] + }; + await patch(itemKey, itemVersion, itemData, newData); + }); + + it('testNewComputerProgramItem', async function () { + const data = await API.createItem('computerProgram', false, true, 'jsonData'); + const key = data.key; + assert.equal(data.itemType, 'computerProgram'); + + const version = '1.0'; + data.versionNumber = version; + + const response = await API.userPut( + config.userID, + `items/${key}`, + JSON.stringify(data), + { "Content-Type": "application/json", "If-Unmodified-Since-Version": data.version } + ); + + Helpers.assertStatusCode(response, 204); + const json = await API.getItem(key, true, 'json'); + assert.equal(json.data.versionNumber, version); + }); + + it('testNewInvalidBookItem', async function () { + const json = await API.getItemTemplate("book"); + + // Missing item type + const json2 = { ...json }; + delete json2.itemType; + let response = await API.userPost( + config.userID, + `items`, + JSON.stringify([json2]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "'itemType' property not provided" }); + + // contentType on non-attachment + const json3 = { ...json }; + json3.contentType = "text/html"; + response = await API.userPost( + config.userID, + `items`, + JSON.stringify([json3]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "'contentType' is valid only for attachment items" }); + }); + + it('testEditTopLevelNote', async function () { + let noteText = "

Test

"; + let json = await API.createNoteItem(noteText, null, true, 'jsonData'); + noteText = "

Test Test

"; + json.note = noteText; + const response = await API.userPut( + config.userID, + `items/${json.key}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + const response2 = await API.userGet( + config.userID, + `items/${json.key}` + ); + Helpers.assertStatusCode(response2, 200); + json = API.getJSONFromResponse(response2).data; + assert.equal(json.note, noteText); + }); + + it('testEditChildNote', async function () { + let noteText = "

Test

"; + const key = await API.createItem("book", { title: "Test" }, true, 'key'); + let json = await API.createNoteItem(noteText, key, true, 'jsonData'); + + noteText = "

Test Test

"; + json.note = noteText; + const response1 = await API.userPut( + config.userID, + "items/" + json.key, + JSON.stringify(json) + ); + assert.equal(response1.status, 204); + const response2 = await API.userGet( + config.userID, + "items/" + json.key + ); + Helpers.assertStatusCode(response2, 200); + json = API.getJSONFromResponse(response2).data; + assert.equal(json.note, noteText); + }); + + it('testEditTitleWithCollectionInMultipleMode', async function () { + const collectionKey = await API.createCollection('Test', false, true, 'key'); + let json = await API.createItem('book', { + title: 'A', + collections: [ + collectionKey, + ], + }, true, 'jsonData'); + const version = json.version; + json.title = 'B'; + + const response = await API.userPost( + config.userID, + `items`, JSON.stringify([json]), + ); + Helpers.assert200ForObject(response, 200); + json = (await API.getItem(json.key, true, 'json')).data; + assert.equal(json.title, 'B'); + assert.isAbove(json.version, version); + }); + + it('testEditTitleWithTagInMultipleMode', async function () { + const tag1 = { + tag: 'foo', + type: 1, + }; + const tag2 = { + tag: 'bar', + }; + + let json = await API.createItem('book', { + title: 'A', + tags: [tag1], + }, true, 'jsonData'); + + assert.equal(json.tags.length, 1); + assert.deepEqual(json.tags[0], tag1); + + const version = json.version; + json.title = 'B'; + json.tags.push(tag2); + + const response = await API.userPost( + config.userID, + `items`, + JSON.stringify([json]), + ); + Helpers.assert200ForObject(response); + json = (await API.getItem(json.key, true, 'json')).data; + + assert.equal(json.title, 'B'); + assert.isAbove(json.version, version); + assert.equal(json.tags.length, 2); + assert.deepEqual(json.tags, [tag2, tag1]); + }); + + it('testNewTopLevelImportedFileAttachment', async function () { + const response = await API.get("items/new?itemType=attachment&linkMode=imported_file"); + const json = JSON.parse(response.data); + const userPostResponse = await API.userPost( + config.userID, + `items`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200(userPostResponse); + }); + + // Disabled -- see note at Zotero_Item::checkTopLevelAttachment() + it('testNewInvalidTopLevelAttachment', async function () { + this.skip(); + }); + + it('testNewEmptyLinkAttachmentItemWithItemKey', async function () { + const key = await API.createItem("book", false, true, 'key'); + await API.createAttachmentItem("linked_url", [], key, true, 'json'); + + let response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + let json = JSON.parse(response.data); + json.parentItem = key; + + json.key = Helpers.uniqueID(); + json.version = 0; + + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + }); + + it('testEditEmptyImportedURLAttachmentItem', async function () { + let key = await API.createItem('book', false, true, 'key'); + let json = await API.createAttachmentItem("imported_url", [], key, true, 'jsonData'); + const version = json.version; + key = json.key; + + + const response = await API.userPut( + config.userID, + `items/${key}`, + JSON.stringify(json), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': version + } + ); + Helpers.assertStatusCode(response, 204); + + json = (await API.getItem(key, true, 'json')).data; + // Item Shouldn't be changed + assert.equal(version, json.version); + }); + + const testEditEmptyLinkAttachmentItem = async () => { + let key = await API.createItem('book', false, true, 'key'); + let json = await API.createAttachmentItem('linked_url', [], key, true, 'jsonData'); + + key = json.key; + const version = json.version; + + const response = await API.userPut( + config.userID, + `items/${key}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assertStatusCode(response, 204); + + json = (await API.getItem(key, true, 'json')).data; + // Item shouldn't change + assert.equal(version, json.version); + return json; + }; + + it('testEditLinkAttachmentItem', async function () { + let json = await testEditEmptyLinkAttachmentItem(); + const key = json.key; + const version = json.version; + + const contentType = "text/xml"; + const charset = "utf-8"; + + json.contentType = contentType; + json.charset = charset; + + const response = await API.userPut( + config.userID, + `items/${key}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + + Helpers.assertStatusCode(response, 204); + + json = (await API.getItem(key, true, 'json')).data; + + assert.equal(json.contentType, contentType); + assert.equal(json.charset, charset); + }); + + it('testEditAttachmentAtomUpdatedTimestamp', async function () { + const xml = await API.createAttachmentItem("linked_file", [], false, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const atomUpdated = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + const json = JSON.parse(data.content); + delete json.dateModified; + json.note = "Test"; + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const response = await API.userPut( + config.userID, + `items/${data.key}`, + JSON.stringify(json), + { "If-Unmodified-Since-Version": data.version } + ); + Helpers.assert204(response); + + const xml2 = await API.getItemXML(data.key); + const atomUpdated2 = Helpers.xpathEval(xml2, '//atom:entry/atom:updated'); + assert.notEqual(atomUpdated2, atomUpdated); + }); + + it('testEditAttachmentAtomUpdatedTimestampTmpZoteroClientHack', async function () { + const xml = await API.createAttachmentItem("linked_file", [], false, true, 'atom'); + const data = API.parseDataFromAtomEntry(xml); + const atomUpdated = Helpers.xpathEval(xml, '//atom:entry/atom:updated'); + const json = JSON.parse(data.content); + json.note = "Test"; + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const response = await API.userPut( + config.userID, + `items/${data.key}`, + JSON.stringify(json), + { "If-Unmodified-Since-Version": data.version } + ); + Helpers.assert204(response); + + const xml2 = await API.getItemXML(data.key); + const atomUpdated2 = Helpers.xpathEval(xml2, '//atom:entry/atom:updated'); + assert.notEqual(atomUpdated2, atomUpdated); + }); + + it('testNewAttachmentItemInvalidLinkMode', async function () { + const response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + const json = JSON.parse(response.data); + + // Invalid linkMode + json.linkMode = "invalidName"; + const newResponse = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(newResponse, { message: "'invalidName' is not a valid linkMode" }); + + // Missing linkMode + delete json.linkMode; + const missingResponse = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(missingResponse, { message: "'linkMode' property not provided" }); + }); + it('testNewAttachmentItemMD5OnLinkedURL', async function () { + let json = await testNewEmptyBookItem(); + const parentKey = json.key; + + const response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + json = JSON.parse(response.data); + json.parentItem = parentKey; + + json.md5 = "c7487a750a97722ae1878ed46b215ebe"; + const postResponse = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(postResponse, { message: "'md5' is valid only for imported and embedded-image attachments" }); + }); + it('testNewAttachmentItemModTimeOnLinkedURL', async function () { + let json = await testNewEmptyBookItem(); + const parentKey = json.key; + + const response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + json = JSON.parse(response.data); + json.parentItem = parentKey; + + json.mtime = "1332807793000"; + const postResponse = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(postResponse, { message: "'mtime' is valid only for imported and embedded-image attachments" }); + }); + it('testMappedCreatorTypes', async function () { + const json = [ + { + itemType: 'presentation', + title: 'Test', + creators: [ + { + creatorType: "author", + name: "Foo" + } + ] + }, + { + itemType: 'presentation', + title: 'Test', + creators: [ + { + creatorType: "editor", + name: "Foo" + } + ] + } + ]; + const response = await API.userPost( + config.userID, + "items", + JSON.stringify(json) + ); + // 'author' gets mapped automatically, others dont + Helpers.assert200ForObject(response); + Helpers.assert400ForObject(response, { index: 1 }); + }); + + it('testNumChildrenJSON', async function () { + let json = await API.createItem("book", false, true, 'json'); + assert.equal(json.meta.numChildren, 0); + + const key = json.key; + + await API.createAttachmentItem("linked_url", [], key, true, 'key'); + + let response = await API.userGet( + config.userID, + `items/${key}` + ); + json = API.getJSONFromResponse(response); + assert.equal(json.meta.numChildren, 1); + + await API.createNoteItem("Test", key, true, 'key'); + + response = await API.userGet( + config.userID, + `items/${key}` + ); + json = API.getJSONFromResponse(response); + assert.equal(json.meta.numChildren, 2); + }); + + it('testNumChildrenAtom', async function () { + let xml = await API.createItem("book", false, true, 'atom'); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'), 0); + const data = API.parseDataFromAtomEntry(xml); + const key = data.key; + + await API.createAttachmentItem("linked_url", [], key, true, 'key'); + + let response = await API.userGet( + config.userID, + `items/${key}?content=json` + ); + xml = API.getXMLFromResponse(response); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'), 1); + + await API.createNoteItem("Test", key, true, 'key'); + + response = await API.userGet( + config.userID, + `items/${key}?content=json` + ); + xml = API.getXMLFromResponse(response); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:numChildren'), 2); + }); + + it('testTop', async function () { + await API.userClear(config.userID); + + const collectionKey = await API.createCollection('Test', false, this, 'key'); + const emptyCollectionKey = await API.createCollection('Empty', false, this, 'key'); + + const parentTitle1 = "Parent Title"; + const childTitle1 = "This is a Test Title"; + const parentTitle2 = "Another Parent Title"; + const parentTitle3 = "Yet Another Parent Title"; + const noteText = "This is a sample note."; + const parentTitleSearch = "title"; + const childTitleSearch = "test"; + const dates = ["2013", "January 3, 2010", ""]; + const orderedDates = [dates[2], dates[1], dates[0]]; + const itemTypes = ["journalArticle", "newspaperArticle", "book"]; + + const parentKeys = []; + const childKeys = []; + + const orderedTitles = [parentTitle1, parentTitle2, parentTitle3].sort(); + const orderedDatesReverse = [...orderedDates].reverse(); + const orderedItemTypes = [...itemTypes].sort(); + const reversedItemTypes = [...orderedItemTypes].reverse(); + + parentKeys.push(await API.createItem(itemTypes[0], { + title: parentTitle1, + date: dates[0], + collections: [ + collectionKey + ] + }, this, 'key')); + + childKeys.push(await API.createAttachmentItem("linked_url", { + title: childTitle1 + }, parentKeys[0], this, 'key')); + + parentKeys.push(await API.createItem(itemTypes[1], { + title: parentTitle2, + date: dates[1] + }, this, 'key')); + + childKeys.push(await API.createNoteItem(noteText, parentKeys[1], this, 'key')); + childKeys.push(await API.createAttachmentItem( + 'embedded_image', + { contentType: "image/png" }, + childKeys[childKeys.length - 1], + this, 'key')); + + // Create item with deleted child that matches child title search + parentKeys.push(await API.createItem(itemTypes[2], { + title: parentTitle3 + }, this, 'key')); + + await API.createAttachmentItem("linked_url", { + title: childTitle1, + deleted: true + }, parentKeys[parentKeys.length - 1], this, 'key'); + + // Add deleted item with non-deleted child + const deletedKey = await API.createItem("book", { + title: "This is a deleted item", + deleted: true, + }, this, 'key'); + + await API.createNoteItem("This is a child note of a deleted item.", deletedKey, this, 'key'); + + const top = async (url, expectedResults = -1) => { + const response = await API.userGet(config.userID, url); + Helpers.assertStatusCode(response, 200); + if (expectedResults !== -1) { + Helpers.assertNumResults(response, expectedResults); + } + return response; + }; + + const checkXml = (response, expectedCount = -1, path = '//atom:entry/zapi:key') => { + const xml = API.getXMLFromResponse(response); + const xpath = Helpers.xpathEval(xml, path, false, true); + if (expectedCount !== -1) { + assert.equal(xpath.length, expectedCount); + } + return xpath; + }; + + let response, xpath, json, done; + + // /top, JSON + response = await top(`items/top`, parentKeys.length); + json = API.getJSONFromResponse(response); + done = []; + for (let item of json) { + assert.include(parentKeys, item.key); + assert.notInclude(done, item.key); + done.push(item.key); + } + + // /top, Atom + response = await top(`items/top?content=json`, parentKeys.length); + xpath = await checkXml(response, parentKeys.length); + for (let parentKey of parentKeys) { + assert.include(xpath, parentKey); + } + + // /top, JSON, in collection + response = await top(`collections/${collectionKey}/items/top`, 1); + json = API.getJSONFromResponse(response); + Helpers.assertNumResults(response, 1); + assert.equal(parentKeys[0], json[0].key); + + // /top, Atom, in collection + response = await top(`collections/${collectionKey}/items/top?content=json`, 1); + xpath = await checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, JSON, in empty collection + response = await top(`collections/${emptyCollectionKey}/items/top`, 0); + Helpers.assertTotalResults(response, 0); + + // /top, keys + response = await top(`items/top?format=keys`); + let keys = response.data.trim().split("\n"); + assert.equal(keys.length, parentKeys.length); + for (let parentKey of parentKeys) { + assert.include(keys, parentKey); + } + + // /top, keys, in collection + response = await top(`collections/${collectionKey}/items/top?format=keys`); + assert.equal(response.data.trim(), parentKeys[0]); + + // /top with itemKey for parent, JSON + response = await top(`items/top?itemKey=${parentKeys[0]}`, 1); + json = API.getJSONFromResponse(response); + assert.equal(parentKeys[0], json[0].key); + + // /top with itemKey for parent, Atom + response = await top(`items/top?content=json&itemKey=${parentKeys[0]}`, 1); + xpath = await checkXml(response); + assert.equal(parentKeys[0], xpath.shift()); + + // /top with itemKey for parent, JSON, in collection + response = await top(`collections/${collectionKey}/items/top?itemKey=${parentKeys[0]}`, 1); + json = API.getJSONFromResponse(response); + assert.equal(parentKeys[0], json[0].key); + + // /top with itemKey for parent, Atom, in collection + response = await top(`collections/${collectionKey}/items/top?content=json&itemKey=${parentKeys[0]}`, 1); + xpath = await checkXml(response); + assert.equal(parentKeys[0], xpath.shift()); + + // /top with itemKey for parent, keys + response = await top(`items/top?format=keys&itemKey=${parentKeys[0]}`); + assert.equal(parentKeys[0], response.data.trim()); + + // /top with itemKey for parent, keys, in collection + response = await top(`collections/${collectionKey}/items/top?format=keys&itemKey=${parentKeys[0]}`); + assert.equal(parentKeys[0], response.data.trim()); + + // /top with itemKey for child, JSON + response = await top(`items/top?itemKey=${childKeys[0]}`, 1); + json = API.getJSONFromResponse(response); + assert.equal(parentKeys[0], json[0].key); + + // /top with itemKey for child, Atom + response = await top(`items/top?content=json&itemKey=${childKeys[0]}`, 1); + xpath = await checkXml(response); + assert.equal(parentKeys[0], xpath.shift()); + + // /top with itemKey for child, keys + response = await top(`items/top?format=keys&itemKey=${childKeys[0]}`); + assert.equal(parentKeys[0], response.data.trim()); + + // /top, Atom, with q for all items + response = await top(`items/top?content=json&q=${parentTitleSearch}`, parentKeys.length); + xpath = await checkXml(response, parentKeys.length); + for (let parentKey of parentKeys) { + assert.include(xpath, parentKey); + } + + // /top, JSON, with q for all items + response = await top(`items/top?q=${parentTitleSearch}`, parentKeys.length); + json = API.getJSONFromResponse(response); + done = []; + for (let item of json) { + assert.include(parentKeys, item.key); + assert.notInclude(done, item.key); + done.push(item.key); + } + + // /top, JSON, in collection, with q for all items + response = await top(`collections/${collectionKey}/items/top?q=${parentTitleSearch}`, 1); + json = API.getJSONFromResponse(response); + assert.equal(parentKeys[0], json[0].key); + + // /top, Atom, in collection, with q for all items + response = await top(`collections/${collectionKey}/items/top?content=json&q=${parentTitleSearch}`, 1); + xpath = await checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, JSON, with q for child item + response = await top(`items/top?q=${childTitleSearch}`, 1); + json = API.getJSONFromResponse(response); + assert.equal(parentKeys[0], json[0].key); + + // /top, Atom, with q for child item + response = await top(`items/top?content=json&q=${childTitleSearch}`, 1); + xpath = checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, JSON, in collection, with q for child item + response = await top(`collections/${collectionKey}/items/top?q=${childTitleSearch}`, 1); + json = API.getJSONFromResponse(response); + assert.equal(parentKeys[0], json[0].key); + + // /top, Atom, in collection, with q for child item + response = await top(`collections/${collectionKey}/items/top?content=json&q=${childTitleSearch}`, 1); + xpath = checkXml(response, 1); + assert.include(xpath, parentKeys[0]); + + // /top, JSON, with q for all items, ordered by title + response = await top(`items/top?q=${parentTitleSearch}&order=title`, parentKeys.length); + json = API.getJSONFromResponse(response); + let returnedTitles = []; + for (let item of json) { + returnedTitles.push(item.data.title); + } + assert.deepEqual(orderedTitles, returnedTitles); + + // /top, Atom, with q for all items, ordered by title + response = await top(`items/top?content=json&q=${parentTitleSearch}&order=title`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/atom:title'); + let orderedResults = xpath.map(val => String(val)); + assert.deepEqual(orderedTitles, orderedResults); + + // /top, Atom, with q for all items, ordered by date asc + response = await top(`items/top?content=json&q=${parentTitleSearch}&order=date&sort=asc`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/atom:content'); + orderedResults = xpath.map(val => JSON.parse(val).date); + assert.deepEqual(orderedDates, orderedResults); + + // /top, JSON, with q for all items, ordered by date asc + response = await top(`items/top?q=${parentTitleSearch}&order=date&sort=asc`, parentKeys.length); + json = API.getJSONFromResponse(response); + orderedResults = Object.entries(json).map(([_, val]) => { + return val.data.date; + }); + assert.deepEqual(orderedDates, orderedResults); + + // /top, Atom, with q for all items, ordered by date desc + response = await top(`items/top?content=json&q=${parentTitleSearch}&order=date&sort=desc`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/atom:content'); + orderedResults = xpath.map(val => JSON.parse(val).date); + assert.deepEqual(orderedDatesReverse, orderedResults); + + // /top, JSON, with q for all items, ordered by date desc + response = await top(`items/top?&q=${parentTitleSearch}&order=date&sort=desc`, parentKeys.length); + json = API.getJSONFromResponse(response); + orderedResults = Object.entries(json).map(([_, val]) => { + return val.data.date; + }); + assert.deepEqual(orderedDatesReverse, orderedResults); + + // /top, Atom, with q for all items, ordered by item type asc + response = await top(`items/top?content=json&q=${parentTitleSearch}&order=itemType`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/zapi:itemType'); + orderedResults = xpath.map(val => String(val)); + assert.deepEqual(orderedItemTypes, orderedResults); + + // /top, JSON, with q for all items, ordered by item type asc + response = await top(`items/top?q=${parentTitleSearch}&order=itemType`, parentKeys.length); + json = API.getJSONFromResponse(response); + orderedResults = Object.entries(json).map(([_, val]) => { + return val.data.itemType; + }); + assert.deepEqual(orderedItemTypes, orderedResults); + + // /top, Atom, with q for all items, ordered by item type desc + response = await top(`items/top?content=json&q=${parentTitleSearch}&order=itemType&sort=desc`, parentKeys.length); + xpath = checkXml(response, parentKeys.length, '//atom:entry/zapi:itemType'); + orderedResults = xpath.map(val => String(val)); + assert.deepEqual(reversedItemTypes, orderedResults); + + // /top, JSON, with q for all items, ordered by item type desc + response = await top(`items/top?q=${parentTitleSearch}&order=itemType&sort=desc`, parentKeys.length); + json = API.getJSONFromResponse(response); + orderedResults = Object.entries(json).map(([_, val]) => { + return val.data.itemType; + }); + assert.deepEqual(reversedItemTypes, orderedResults); + }); + + it('testParentItem', async function () { + let json = await API.createItem("book", false, true, "jsonData"); + let parentKey = json.key; + + json = await API.createAttachmentItem("linked_file", [], parentKey, true, 'jsonData'); + let childKey = json.key; + let childVersion = json.version; + + assert.property(json, "parentItem"); + assert.equal(parentKey, json.parentItem); + + // Remove the parent, making the child a standalone attachment + delete json.parentItem; + + let response = await API.userPut( + config.userID, + `items/${childKey}`, + JSON.stringify(json), + { "If-Unmodified-Since-Version": childVersion } + ); + Helpers.assert204(response); + + json = (await API.getItem(childKey, true, 'json')).data; + assert.notProperty(json, "parentItem"); + }); + + it('testParentItemPatch', async function () { + let json = await API.createItem("book", false, true, 'jsonData'); + const parentKey = json.key; + + json = await API.createAttachmentItem("linked_file", [], parentKey, true, 'jsonData'); + const childKey = json.key; + let childVersion = json.version; + + assert.property(json, "parentItem"); + assert.equal(parentKey, json.parentItem); + + // With PATCH, parent shouldn't be removed even though unspecified + let response = await API.userPatch( + config.userID, + `items/${childKey}`, + JSON.stringify({ title: "Test" }), + { "If-Unmodified-Since-Version": childVersion }, + ); + + Helpers.assert204(response); + + json = (await API.getItem(childKey, true, "json")).data; + assert.property(json, "parentItem"); + + childVersion = json.version; + + // But it should be removed with parentItem: false + response = await API.userPatch( + config.userID, + `items/${childKey}`, + JSON.stringify({ parentItem: false }), + { "If-Unmodified-Since-Version": childVersion }, + ); + Helpers.assert204(response); + json = (await API.getItem(childKey, true, "json")).data; + assert.notProperty(json, "parentItem"); + }); + + it('testDate', async function () { + const date = "Sept 18, 2012"; + const parsedDate = '2012-09-18'; + + let json = await API.createItem("book", { date: date }, true, 'jsonData'); + const key = json.key; + + let response = await API.userGet( + config.userID, + `items/${key}` + ); + json = API.getJSONFromResponse(response); + assert.equal(json.data.date, date); + assert.equal(json.meta.parsedDate, parsedDate); + + let xml = await API.getItem(key, true, 'atom'); + assert.equal(Helpers.xpathEval(xml, '//atom:entry/zapi:parsedDate'), parsedDate); + }); + + + it('test_patch_of_item_in_trash_without_deleted_should_not_remove_it_from_trash', async function () { + let json = await API.createItem("book", { + deleted: true + }, this, 'json'); + + let data = [ + { + key: json.key, + version: json.version, + title: 'A' + } + ]; + let response = await API.postItems(data); + let jsonResponse = API.getJSONFromResponse(response); + + assert.property(jsonResponse.successful[0].data, 'deleted'); + Helpers.assertEquals(1, jsonResponse.successful[0].data.deleted); + }); + + it('test_deleting_parent_item_should_delete_note_and_embedded_image_attachment', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + // Create embedded-image attachment + let noteKey = await API.createNoteItem( + '

Test

', itemKey, this, 'key' + ); + // Create image annotation + let attachmentKey = await API.createAttachmentItem( + 'embedded_image', { contentType: 'image/png' }, noteKey, this, 'key' + ); + // Check that all items can be found + let response = await API.userGet( + config.userID, + "items?itemKey=" + itemKey + "," + noteKey + "," + attachmentKey + ); + Helpers.assertNumResults(response, 3); + response = await API.userDelete( + config.userID, + "items/" + itemKey, + { "If-Unmodified-Since-Version": itemVersion } + ); + Helpers.assert204(response); + response = await API.userGet( + config.userID, + "items?itemKey=" + itemKey + "," + noteKey + "," + attachmentKey + ); + json = API.getJSONFromResponse(response); + Helpers.assertNumResults(response, 0); + }); + + it('testTrash', async function () { + await API.userClear(config.userID); + + const key1 = await API.createItem("book", false, this, 'key'); + const key2 = await API.createItem("book", { + deleted: 1 + }, this, 'key'); + + // Item should show up in trash + let response = await API.userGet( + config.userID, + "items/trash" + ); + let json = API.getJSONFromResponse(response); + Helpers.assertCount(1, json); + Helpers.assertEquals(key2, json[0].key); + + // And not show up in main items + response = await API.userGet( + config.userID, + "items" + ); + json = API.getJSONFromResponse(response); + Helpers.assertCount(1, json); + Helpers.assertEquals(key1, json[0].key); + + // Including with ?itemKey + response = await API.userGet( + config.userID, + "items?itemKey=" + key2 + ); + json = API.getJSONFromResponse(response); + Helpers.assertCount(0, json); + }); + + it('test_should_convert_child_note_to_top_level_and_add_to_collection_via_PUT', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + let parentItemKey = await API.createItem("book", false, this, 'key'); + let noteJSON = await API.createNoteItem("", parentItemKey, this, 'jsonData'); + delete noteJSON.parentItem; + noteJSON.collections = [collectionKey]; + let response = await API.userPut( + config.userID, + `items/${noteJSON.key}`, + JSON.stringify(noteJSON), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + let json = (await API.getItem(noteJSON.key, this, 'json')).data; + assert.notProperty(json, 'parentItem'); + Helpers.assertCount(1, json.collections); + Helpers.assertEquals(collectionKey, json.collections[0]); + }); + + it('test_should_reject_invalid_content_type_for_embedded_image_attachment', async function () { + let noteKey = await API.createNoteItem("Test", null, this, 'key'); + let response = await API.get("items/new?itemType=attachment&linkMode=embedded_image"); + let json = JSON.parse(response.data); + json.parentItem = noteKey; + json.contentType = 'application/pdf'; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "Embedded-image attachment must have an image content type" }); + }); + + it('testPatchNote', async function () { + let text = "

Test

"; + let newText = "

Test 2

"; + let json = await API.createNoteItem(text, false, this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + + let response = await API.userPatch( + config.userID, + "items/" + itemKey, + JSON.stringify({ + note: newText + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion + } + ); + + Helpers.assert204(response); + json = (await API.getItem(itemKey, this, 'json')).data; + + Helpers.assertEquals(newText, json.note); + let headerVersion = parseInt(response.headers["last-modified-version"][0]); + assert.isAbove(headerVersion, itemVersion); + Helpers.assertEquals(json.version, headerVersion); + }); + + it('test_should_create_embedded_image_attachment_for_note', async function () { + let noteKey = await API.createNoteItem("Test", null, this, 'key'); + let imageKey = await API.createAttachmentItem( + 'embedded_image', { contentType: 'image/png' }, noteKey, this, 'key' + ); + assert.ok(imageKey); + }); + + it('test_should_return_409_if_a_note_references_a_note_as_a_parent_item', async function () { + let parentKey = await API.createNoteItem("

Parent

", null, this, 'key'); + let json = await API.createNoteItem("

Parent

", parentKey, this); + Helpers.assert409ForObject(json, "Parent item cannot be a note or attachment"); + Helpers.assertEquals(parentKey, json.failed[0].data.parentItem); + }); + + it('testDateModifiedTmpZoteroClientHack', async function () { + let objectType = 'item'; + let objectTypePlural = API.getPluralObjectType(objectType); + + let json = await API.createItem("videoRecording", { title: "Test" }, this, 'jsonData'); + + let objectKey = json.key; + let dateModified1 = json.dateModified; + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // If no explicit dateModified, use current timestamp + json.title = "Test 2"; + delete json.dateModified; + let response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json), + { // TODO: Remove + "User-Agent": "Firefox" + } + ); + Helpers.assert204(response); + + if (objectType == 'item') { + json = (await API.getItem(objectKey, this, 'json')).data; + } + + + let dateModified2 = json.dateModified; + assert.notEqual(dateModified1, dateModified2); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // If dateModified provided and hasn't changed, use that + json.title = "Test 3"; + json.dateModified = dateModified2.replace(/T|Z/g, ' ').trim(); + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json), + { + "User-Agent": "Firefox" + } + ); + Helpers.assert204(response); + + if (objectType == 'item') { + json = (await API.getItem(objectKey, this, 'json')).data; + } + Helpers.assertEquals(dateModified2, json.dateModified); + + let newDateModified = "2013-03-03T21:33:53Z"; + // If dateModified is provided and has changed, use that + json.title = "Test 4"; + json.dateModified = newDateModified; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json), + { + "User-Agent": "Firefox" + } + ); + Helpers.assert204(response); + + if (objectType == 'item') { + json = (await API.getItem(objectKey, this, 'json')).data; + } + Helpers.assertEquals(newDateModified, json.dateModified); + }); + + it('test_top_should_return_top_level_item_for_three_level_hierarchy', async function () { + await API.userClear(config.userID); + + // Create parent item, PDF attachment, and annotation + let itemKey = await API.createItem("book", { title: 'aaa' }, this, 'key'); + let attachmentKey = await API.createAttachmentItem("imported_url", { + contentType: 'application/pdf', + title: 'bbb' + }, itemKey, this, 'key'); + let _ = await API.createAnnotationItem('highlight', { annotationComment: 'ccc' }, attachmentKey, this, 'key'); + + // Search for descendant items in /top mode + let response = await API.userGet(config.userID, "items/top?q=bbb"); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals("aaa", json[0].data.title); + + response = await API.userGet(config.userID, "items/top?itemType=annotation"); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals("aaa", json[0].data.title); + + response = await API.userGet(config.userID, `items/top?itemKey=${attachmentKey}`); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals("aaa", json[0].data.title); + }); + + it('test_unfiled', async function () { + this.skip(); + await API.userClear(config.userID); + + let collectionKey = await API.createCollection('Test', false, this, 'key'); + await API.createItem("book", { title: 'aaa' }, this, 'key'); + await API.createItem("book", { title: 'bbb' }, this, 'key'); + + await API.createItem("book", { title: 'ccc', collections: [collectionKey] }, this, 'key'); + let parentBookInCollection = await API.createItem("book", { title: 'ddd', collections: [collectionKey] }, this, 'key'); + await API.createNoteItem("some note", parentBookInCollection, this, 'key'); + + let response = await API.userGet(config.userID, `items/unfiled?sort=title`); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals("aaa", json[0].data.title); + Helpers.assertEquals("bbb", json[1].data.title); + }); + + /** + * Date Modified shouldn't be changed if 1) dateModified is provided or 2) certain fields are changed + */ + it('testDateModifiedNoChange', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + + let json = await API.createItem('book', false, this, 'jsonData'); + let modified = json.dateModified; + + for (let i = 1; i <= 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1000)); + + switch (i) { + case 1: + json.title = 'A'; + break; + + case 2: + // For all subsequent tests, unset field, which would normally cause it to be updated + delete json.dateModified; + + json.collections = [collectionKey]; + break; + + case 3: + json.deleted = true; + break; + + case 4: + json.deleted = false; + break; + + case 5: + json.tags = [{ + tag: 'A' + }]; + break; + } + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { + "If-Unmodified-Since-Version": json.version, + // TODO: Remove + "User-Agent": "Firefox" + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response).successful[0].data; + Helpers.assertEquals(modified, json.dateModified, "Date Modified changed on loop " + i); + } + }); + + it('testDateModifiedCollectionChange', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + let json = await API.createItem("book", { title: "Test" }, this, 'jsonData'); + + let objectKey = json.key; + let dateModified1 = json.dateModified; + + json.collections = [collectionKey]; + + // Make sure we're in the next second + await new Promise(resolve => setTimeout(resolve, 1000)); + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + + json = (await API.getItem(objectKey, this, 'json')).data; + let dateModified2 = json.dateModified; + + // Date Modified shouldn't have changed + Helpers.assertEquals(dateModified1, dateModified2); + }); + + it('test_should_return_409_if_an_attachment_references_a_note_as_a_parent_item', async function () { + let parentKey; + await API.createNoteItem('

Parent

', null, this, 'key').then((res) => { + parentKey = res; + }); + let json; + await API.createAttachmentItem('imported_file', [], parentKey, this, 'responseJSON').then((res) => { + json = res; + }); + Helpers.assert409ForObject(json, 'Parent item cannot be a note or attachment'); + Helpers.assertEquals(parentKey, json.failed[0].data.parentItem); + }); + + it('testDateAddedNewItem8601TZ', async function () { + const objectType = 'item'; + const dateAdded = "2013-03-03T17:33:53-0400"; + const dateAddedUTC = "2013-03-03T21:33:53Z"; + let itemData = { + title: "Test", + dateAdded: dateAdded + }; + let data; + switch (objectType) { + case 'item': + data = await API.createItem("videoRecording", itemData, this, 'jsonData'); + break; + } + assert.equal(dateAddedUTC, data.dateAdded); + }); + + it('testDateAccessed8601TZ', async function () { + let date = '2014-02-01T01:23:45-0400'; + let dateUTC = '2014-02-01T05:23:45Z'; + let data = await API.createItem("book", { + accessDate: date + }, this, 'jsonData'); + Helpers.assertEquals(dateUTC, data.accessDate); + }); + + it('test_should_reject_embedded_image_attachment_without_parent', async function () { + let response = await API.get("items/new?itemType=attachment&linkMode=embedded_image"); + let json = JSON.parse(response.data); + json.parentItem = false; + json.contentType = 'image/png'; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "Embedded-image attachment must have a parent item" }); + }); + + it('testNewEmptyAttachmentFields', async function () { + let key = await API.createItem("book", false, this, 'key'); + let json = await API.createAttachmentItem("imported_url", [], key, this, 'jsonData'); + assert.notOk(json.md5); + assert.notOk(json.mtime); + }); + + it('testDateUnparseable', async function () { + let json = await API.createItem("book", { + date: 'n.d.' + }, this, 'jsonData'); + let key = json.key; + + let response = await API.userGet( + config.userID, + "items/" + key + ); + json = API.getJSONFromResponse(response); + Helpers.assertEquals('n.d.', json.data.date); + + // meta.parsedDate (JSON) + assert.notProperty(json.meta, 'parsedDate'); + + // zapi:parsedDate (Atom) + let xml = await API.getItem(key, this, 'atom'); + Helpers.assertCount(0, Helpers.xpathEval(xml, '/atom:entry/zapi:parsedDate', false, true).length); + }); + + /** + * Changing existing 'md5' and 'mtime' values to null was originally prevented, but some client + * versions were sending null, so now we just ignore it. + * + * At some point, we should check whether any clients are still doing this and restore the + * restriction if not. These should only be cleared on a storage purge. + */ + it('test_should_ignore_null_for_existing_storage_properties', async function () { + let key = await API.createItem("book", [], this, 'key'); + let json = await API.createAttachmentItem( + "imported_url", + { + md5: Helpers.md5(Helpers.uniqueID(50)), + mtime: Date.now() + }, + key, + this, + 'jsonData' + ); + + key = json.key; + let version = json.version; + + let props = ["md5", "mtime"]; + for (let prop of props) { + let json2 = { ...json }; + json2[prop] = null; + let response = await API.userPut( + config.userID, + "items/" + key, + JSON.stringify(json2), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assert204(response); + } + + let json3 = await API.getItem(json.key); + Helpers.assertEquals(json.md5, json3.data.md5); + Helpers.assertEquals(json.mtime, json3.data.mtime); + }); + + it('test_should_reject_changing_parent_of_embedded_image_attachment', async function () { + let note1Key = await API.createNoteItem("Test 1", null, this, 'key'); + let note2Key = await API.createNoteItem("Test 2", null, this, 'key'); + let response = await API.get("items/new?itemType=attachment&linkMode=embedded_image"); + let json = JSON.parse(response.data); + json.parentItem = note1Key; + json.contentType = 'image/png'; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + let key = json.successful[0].key; + json = await API.getItem(key, this, 'json'); + + // Change the parent item + json = { + version: json.version, + parentItem: note2Key + }; + response = await API.userPatch( + config.userID, + `items/${key}`, + JSON.stringify(json) + ); + Helpers.assert400(response, "Cannot change parent item of embedded-image attachment"); + }); + + it('test_should_convert_child_attachment_to_top_level_and_add_to_collection_via_PATCH_without_parentItem_false', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + let parentItemKey = await API.createItem("book", false, this, 'key'); + let attachmentJSON = await API.createAttachmentItem("linked_url", [], parentItemKey, this, 'jsonData'); + delete attachmentJSON.parentItem; + attachmentJSON.collections = [collectionKey]; + let response = await API.userPatch( + config.userID, + "items/" + attachmentJSON.key, + JSON.stringify(attachmentJSON) + ); + Helpers.assert204(response); + let json = (await API.getItem(attachmentJSON.key, this, 'json')).data; + assert.notProperty(json, 'parentItem'); + Helpers.assertCount(1, json.collections); + Helpers.assertEquals(collectionKey, json.collections[0]); + }); + + /** + * Date Modified should be updated when a field is changed if not included in upload + */ + it('testDateModifiedChangeOnEdit', async function () { + let json = await API.createAttachmentItem("linked_file", [], false, this, 'jsonData'); + let modified = json.dateModified; + delete json.dateModified; + json.note = "Test"; + await new Promise(resolve => setTimeout(resolve, 1000)); + const headers = { "If-Unmodified-Since-Version": json.version }; + const response = await API.userPut( + config.userID, + "items/" + json.key, + JSON.stringify(json), + headers + ); + Helpers.assert204(response); + json = (await API.getItem(json.key, this, 'json')).data; + assert.notEqual(modified, json.dateModified); + }); + + it('test_patch_of_item_should_set_trash_state', async function () { + let json = await API.createItem("book", [], this, 'json'); + + let data = [ + { + key: json.key, + version: json.version, + deleted: true + } + ]; + let response = await API.postItems(data); + json = API.getJSONFromResponse(response); + + assert.property(json.successful[0].data, 'deleted'); + Helpers.assertEquals(1, json.successful[0].data.deleted); + }); + + it('testCreateLinkedFileAttachment', async function () { + let key = await API.createItem("book", false, this, 'key'); + let path = 'attachments:tést.txt'; + let json = await API.createAttachmentItem( + "linked_file", { + path: path + }, key, this, 'jsonData' + ); + Helpers.assertEquals('linked_file', json.linkMode); + // Linked file should have path + Helpers.assertEquals(path, json.path); + // And shouldn't have other attachment properties + assert.notProperty(json, 'filename'); + assert.notProperty(json, 'md5'); + assert.notProperty(json, 'mtime'); + }); + + it('test_should_convert_child_note_to_top_level_and_add_to_collection_via_PATCH', async function () { + let collectionKey = await API.createCollection('Test', false, this, 'key'); + let parentItemKey = await API.createItem("book", false, this, 'key'); + let noteJSON = await API.createNoteItem("", parentItemKey, this, 'jsonData'); + noteJSON.parentItem = false; + noteJSON.collections = [collectionKey]; + let response = await API.userPatch( + config.userID, + `items/${noteJSON.key}`, + JSON.stringify(noteJSON) + ); + Helpers.assert204(response); + let json = await API.getItem(noteJSON.key, this, 'json'); + json = json.data; + assert.notProperty(json, 'parentItem'); + Helpers.assertCount(1, json.collections); + Helpers.assertEquals(collectionKey, json.collections[0]); + }); + + it('test_createdByUser', async function () { + let json = await API.groupCreateItem( + config.ownedPrivateGroupID, + 'book', + [], + true, + 'json' + ); + Helpers.assertEquals(config.userID, json.meta.createdByUser.id); + Helpers.assertEquals(config.username, json.meta.createdByUser.username); + // TODO: Name and URI + }); + + it('testPatchNoteOnBookError', async function () { + let json = await API.createItem("book", [], this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + + let response = await API.userPatch( + config.userID, + `items/${itemKey}`, + JSON.stringify({ + note: "Test" + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion + } + ); + Helpers.assert400(response, "'note' property is valid only for note and attachment items"); + }); + + it('test_deleting_parent_item_should_delete_attachment_and_annotation', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + + json = await API.createAttachmentItem( + "imported_file", { contentType: 'application/pdf' }, itemKey, this, 'jsonData' + ); + let attachmentKey = json.key; + + let annotationKey = await API.createAnnotationItem( + 'highlight', + { annotationComment: 'ccc' }, + attachmentKey, + this, + 'key' + ); + + const response = await API.userGet( + config.userID, + `items?itemKey=${itemKey},${attachmentKey},${annotationKey}` + ); + Helpers.assertNumResults(response, 3); + + const deleteResponse = await API.userDelete( + config.userID, + `items/${itemKey}`, + { 'If-Unmodified-Since-Version': itemVersion } + ); + Helpers.assert204(deleteResponse); + + const checkResponse = await API.userGet( + config.userID, + `items?itemKey=${itemKey},${attachmentKey},${annotationKey}` + ); + json = API.getJSONFromResponse(checkResponse); + Helpers.assertNumResults(checkResponse, 0); + }); + + it('test_deleting_group_library_attachment_should_delete_lastPageIndex_setting_for_all_users', async function () { + const json = await API.groupCreateAttachmentItem( + config.ownedPrivateGroupID, + "imported_file", + { contentType: 'application/pdf' }, + null, + this, + 'jsonData' + ); + const attachmentKey = json.key; + const attachmentVersion = json.version; + + // Add setting to both group members + // Set as user 1 + let settingKey = `lastPageIndex_g${config.ownedPrivateGroupID}_${attachmentKey}`; + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify({ + value: 123, + version: 0 + }), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + + // Set as user 2 + API.useAPIKey(config.user2APIKey); + response = await API.userPut( + config.userID2, + `settings/${settingKey}`, + JSON.stringify({ + value: 234, + version: 0 + }), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + + API.useAPIKey(config.apiKey); + + // Delete group item + response = await API.groupDelete( + config.ownedPrivateGroupID, + `items/${attachmentKey}`, + { "If-Unmodified-Since-Version": attachmentVersion } + ); + Helpers.assert204(response); + + // Setting should be gone for both group users + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assert404(response); + + response = await API.superGet( + `users/${config.userID2}/settings/${settingKey}` + ); + Helpers.assert404(response); + }); + + it('test_deleting_user_library_attachment_should_delete_lastPageIndex_setting', async function () { + let json = await API.createAttachmentItem('imported_file', { contentType: 'application/pdf' }, null, this, 'jsonData'); + let attachmentKey = json.key; + let attachmentVersion = json.version; + + let settingKey = `lastPageIndex_u_${attachmentKey}`; + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify({ + value: 123, + version: 0, + }), + { 'Content-Type': 'application/json' } + ); + Helpers.assert204(response); + + response = await API.userDelete( + config.userID, + `items/${attachmentKey}`, + { 'If-Unmodified-Since-Version': attachmentVersion } + ); + Helpers.assert204(response); + + response = await API.userGet(config.userID, `settings/${settingKey}`); + Helpers.assert404(response); + + // Setting shouldn't be in delete log + response = await API.userGet(config.userID, `deleted?since=${attachmentVersion}`); + json = API.getJSONFromResponse(response); + assert.notInclude(json.settings, settingKey); + }); + + it('test_should_reject_linked_file_attachment_in_group', async function () { + let key = await API.groupCreateItem( + config.ownedPrivateGroupID, + "book", + false, + this, + "key" + ); + const path = "attachments:tést.txt"; + let response = await API.groupCreateAttachmentItem( + config.ownedPrivateGroupID, + "linked_file", + { path: path }, + key, + this, + "response" + ); + Helpers.assert400ForObject( + response, + { message: "Linked files can only be added to user libraries" } + ); + }); + + it('test_deleting_linked_file_attachment_should_delete_child_annotation', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + let itemKey = json.key; + + let attachmentKey = await API.createAttachmentItem( + "linked_file", { contentType: "application/pdf" }, itemKey, this, 'key' + ); + json = await API.createAnnotationItem( + 'highlight', {}, attachmentKey, this, 'jsonData' + ); + let annotationKey = json.key; + let version = json.version; + + // Delete parent item + let response = await API.userDelete( + config.userID, + `items?itemKey=${attachmentKey}`, + { "If-Unmodified-Since-Version": version } + ); + Helpers.assert204(response); + + // Child items should be gone + response = await API.userGet( + config.userID, + `items?itemKey=${itemKey},${attachmentKey},${annotationKey}` + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + }); + + it('test_should_move_attachment_with_annotation_under_regular_item', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + let itemKey = json.key; + + // Create standalone attachment to start + json = await API.createAttachmentItem( + "imported_file", { contentType: 'application/pdf' }, null, this, 'jsonData' + ); + let attachmentKey = json.key; + + // Create image annotation + let annotationKey = await API.createAnnotationItem('highlight', {}, attachmentKey, this, 'key'); + + // /top for the annotation key should return the attachment + let response = await API.userGet( + config.userID, + "items/top?itemKey=" + annotationKey + ); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(attachmentKey, json[0].key); + + // Move attachment under regular item + json[0].data.parentItem = itemKey; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json[0].data]) + ); + Helpers.assert200ForObject(response); + + // /top for the annotation key should now return the regular item + response = await API.userGet( + config.userID, + "items/top?itemKey=" + annotationKey + ); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(itemKey, json[0].key); + }); + + it('testDateAddedNewItem8601', async function () { + const objectType = 'item'; + + const dateAdded = "2013-03-03T21:33:53Z"; + + let itemData = { + title: "Test", + dateAdded: dateAdded + }; + let data; + if (objectType == 'item') { + data = await API.createItem("videoRecording", itemData, this, 'jsonData'); + } + Helpers.assertEquals(dateAdded, data.dateAdded); + }); + + it('test_should_reject_embedded_note_for_embedded_image_attachment', async function () { + let noteKey = await API.createNoteItem("Test", null, this, 'key'); + let response = await API.get("items/new?itemType=attachment&linkMode=embedded_image"); + let json = JSON.parse(response.data); + json.parentItem = noteKey; + json.note = '

Foo

'; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "'note' property is not valid for embedded images" }); + }); + + it('test_deleting_parent_item_should_delete_attachment_and_child_annotation', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + let itemKey = json.key; + + let attachmentKey = await API.createAttachmentItem( + "imported_url", + { contentType: "application/pdf" }, + itemKey, + this, + 'key' + ); + json = await API.createAnnotationItem('highlight', {}, attachmentKey, this, 'jsonData'); + let annotationKey = json.key; + let version = json.version; + + // Delete parent item + let response = await API.userDelete( + config.userID, + "items?itemKey=" + itemKey, + { "If-Unmodified-Since-Version": version } + ); + Helpers.assert204(response); + + // All items should be gone + response = await API.userGet( + config.userID, + "items?itemKey=" + itemKey + "," + attachmentKey + "," + annotationKey + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 0); + }); + + it('test_should_preserve_createdByUserID_on_undelete', async function () { + const json = await API.groupCreateItem( + config.ownedPrivateGroupID, "book", false, this, 'json' + ); + const jsonData = json.data; + + assert.equal(json.meta.createdByUser.username, config.username); + + const response = await API.groupDelete( + config.ownedPrivateGroupID, + `items/${json.key}`, + { "If-Unmodified-Since-Version": json.version } + ); + Helpers.assert204(response); + + API.useAPIKey(config.user2APIKey); + jsonData.version = 0; + const postData = JSON.stringify([jsonData]); + const postResponse = await API.groupPost( + config.ownedPrivateGroupID, + "items", + postData, + { "Content-Type": "application/json" } + ); + const jsonResponse = API.getJSONFromResponse(postResponse); + + // createdByUser shouldn't have changed + assert.equal( + jsonResponse.successful[0].meta.createdByUser.username, + config.username + ); + }); + + it('testDateAccessedSQL', async function () { + let date = '2014-02-01 01:23:45'; + let date8601 = '2014-02-01T01:23:45Z'; + let data = await API.createItem("book", { + accessDate: date + }, this, 'jsonData'); + Helpers.assertEquals(date8601, data.accessDate); + }); + + it('testPatchAttachment', async function () { + let json = await API.createAttachmentItem("imported_file", [], false, this, 'jsonData'); + let itemKey = json.key; + let itemVersion = json.version; + + let filename = "test.pdf"; + let mtime = 1234567890000; + let md5 = "390d914fdac33e307e5b0e1f3dba9da2"; + + let response = await API.userPatch( + config.userID, + `items/${itemKey}`, + JSON.stringify({ + filename: filename, + mtime: mtime, + md5: md5, + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": itemVersion + } + ); + Helpers.assert204(response); + json = (await API.getItem(itemKey, this, 'json')).data; + + Helpers.assertEquals(filename, json.filename); + Helpers.assertEquals(mtime, json.mtime); + Helpers.assertEquals(md5, json.md5); + let headerVersion = parseInt(response.headers["last-modified-version"][0]); + assert.isAbove(headerVersion, itemVersion); + Helpers.assertEquals(json.version, headerVersion); + }); + + it('test_should_move_attachment_with_annotation_out_from_under_regular_item', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + let itemKey = json.key; + + // Create standalone attachment to start + let attachmentJSON = await API.createAttachmentItem( + "imported_file", { contentType: 'application/pdf' }, itemKey, this, 'jsonData' + ); + let attachmentKey = attachmentJSON.key; + + // Create image annotation + let annotationKey = await API.createAnnotationItem('highlight', {}, attachmentKey, this, 'key'); + + // /top for the annotation key should return the item + let response = await API.userGet( + config.userID, + "items/top?itemKey=" + annotationKey + ); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(itemKey, json[0].key); + + // Move attachment under regular item + attachmentJSON.parentItem = false; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([attachmentJSON]) + ); + Helpers.assert200ForObject(response); + + // /top for the annotation key should now return the attachment item + response = await API.userGet( + config.userID, + "items/top?itemKey=" + annotationKey + ); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(attachmentKey, json[0].key); + }); + + it('test_should_allow_emoji_in_title', async function () { + let title = "🐶"; + + let key = await API.createItem("book", { title: title }, this, 'key'); + + // Test entry (JSON) + let response = await API.userGet( + config.userID, + "items/" + key + ); + assert.include(response.data, "\"title\": \"" + title + "\""); + + // Test feed (JSON) + response = await API.userGet( + config.userID, + "items" + ); + assert.include(response.data, "\"title\": \"" + title + "\""); + + // Test entry (Atom) + response = await API.userGet( + config.userID, + "items/" + key + "?content=json" + ); + assert.include(response.data, "\"title\": \"" + title + "\""); + + // Test feed (Atom) + response = await API.userGet( + config.userID, + "items?content=json" + ); + assert.include(response.data, "\"title\": \"" + title + "\""); + }); + + it('test_should_return_409_on_missing_parent', async function () { + const missingParentKey = "BDARG2AV"; + const json = await API.createNoteItem("

test

", missingParentKey, this); + Helpers.assert409ForObject(json, "Parent item " + missingParentKey + " not found"); + Helpers.assertEquals(missingParentKey, json.failed[0].data.parentItem); + }); + + it('test_num_children_and_children_on_attachment_with_annotation', async function () { + let key = await API.createItem("book", false, this, 'key'); + let attachmentKey = await API.createAttachmentItem("imported_url", { contentType: 'application/pdf', title: 'bbb' }, key, this, 'key'); + await API.createAnnotationItem("image", { annotationComment: 'ccc' }, attachmentKey, this, 'key'); + let response = await API.userGet(config.userID, `items/${attachmentKey}`); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(1, json.meta.numChildren); + response = await API.userGet(config.userID, `items/${attachmentKey}/children`); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + Helpers.assertCount(1, json); + Helpers.assertEquals('ccc', json[0].data.annotationComment); + }); + + /** + * If null is passed for a value, it should be treated the same as an empty string, not create + * a NULL in the database. + * + * TODO: Since we don't have direct access to the database, our test for this is changing the + * item type and then trying to retrieve it, which isn't ideal. Some way of checking the DB + * state would be useful. + */ + it('test_should_treat_null_value_as_empty_string', async function () { + let json = { + itemType: 'book', + numPages: null + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]) + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + let key = json.successful[0].key; + json = await API.getItem(key, this, 'json'); + + // Change the item type to a type without the field + json = { + version: json.version, + itemType: 'journalArticle' + }; + await API.userPatch( + config.userID, + "items/" + key, + JSON.stringify(json) + ); + + json = await API.getItem(key, this, 'json'); + assert.notProperty(json, 'numPages'); + }); + + it('testLibraryGroup', async function () { + let json = await API.groupCreateItem( + config.ownedPrivateGroupID, + 'book', + [], + this, + 'json' + ); + assert.equal('group', json.library.type); + assert.equal( + config.ownedPrivateGroupID, + json.library.id + ); + assert.equal( + config.ownedPrivateGroupName, + json.library.name + ); + Helpers.assertRegExp( + /^https?:\/\/[^/]+\/groups\/[0-9]+$/, + json.library.links.alternate.href + ); + assert.equal('text/html', json.library.links.alternate.type); + }); + + /** + * It should be possible to edit an existing PDF attachment without sending 'contentType' + * (which would cause a new attachment to be rejected) + * Disabled -- see note at Zotero_Item::checkTopLevelAttachment() + */ + it('testPatchTopLevelAttachment', async function () { + this.skip(); + let json = await API.createAttachmentItem("imported_url", { + title: 'A', + contentType: 'application/pdf', + filename: 'test.pdf' + }, false, this, 'jsonData'); + + // With 'attachment' and 'linkMode' + json = { + itemType: 'attachment', + linkMode: 'imported_url', + key: json.key, + version: json.version, + title: 'B' + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = (await API.getItem(json.key, this, 'json')).data; + Helpers.assertEquals("B", json.title); + + // Without 'linkMode' + json = { + itemType: 'attachment', + key: json.key, + version: json.version, + title: 'C' + }; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = (await API.getItem(json.key, this, 'json')).data; + Helpers.assertEquals("C", json.title); + + // Without 'itemType' or 'linkMode' + json = { + key: json.key, + version: json.version, + title: 'D' + }; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = (await API.getItem(json.key, this, 'json')).data; + Helpers.assertEquals("D", json.title); + }); + + it('testTopWithSince', async function () { + await API.userClear(config.userID); + + let version1 = await API.getLibraryVersion(); + let parentKeys = []; + parentKeys[0] = await API.createItem('book', [], this, 'key'); + let childKeys = []; + childKeys[0] = await API.createAttachmentItem('linked_url', [], parentKeys[0], this, 'key'); + parentKeys[1] = await API.createItem('journalArticle', [], this, 'key'); + let version4 = await API.getLibraryVersion(); + childKeys[1] = await API.createNoteItem('', parentKeys[1], this, 'key'); + parentKeys[2] = await API.createItem('book', [], this, 'key'); + + let response = await API.userGet( + config.userID, + 'items/top?since=' + version1 + ); + Helpers.assertNumResults(response, 3); + + response = await API.userGet( + config.userID, + 'items?since=' + version1 + ); + Helpers.assertNumResults(response, 5); + + response = await API.userGet( + config.userID, + 'items/top?format=versions&since=' + version4 + ); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + let keys = Object.keys(json); + Helpers.assertEquals(parentKeys[2], keys[0]); + }); + + it('testDateAccessed8601', async function () { + let date = '2014-02-01T01:23:45Z'; + let data = await API.createItem("book", { + accessDate: date + }, this, 'jsonData'); + assert.equal(date, data.accessDate); + }); + + it('testLibraryUser', async function () { + let json = await API.createItem('book', false, this, 'json'); + Helpers.assertEquals('user', json.library.type); + Helpers.assertEquals(config.userID, json.library.id); + Helpers.assertEquals(config.displayName, json.library.name); + Helpers.assertRegExp('^https?://[^/]+/' + config.username, json.library.links.alternate.href); + Helpers.assertEquals('text/html', json.library.links.alternate.type); + }); + + it('test_should_return_409_on_missing_collection', async function () { + let missingCollectionKey = "BDARG2AV"; + let requestPayload = { collections: [missingCollectionKey] }; + let json = await API.createItem("book", requestPayload, this); + Helpers.assert409ForObject(json, `Collection ${missingCollectionKey} not found`); + Helpers.assertEquals(missingCollectionKey, json.failed[0].data.collection); + }); + + it('testIncludeTrashed', async function () { + await API.userClear(config.userID); + + let key1 = await API.createItem("book", false, this, 'key'); + let key2 = await API.createItem("book", { + deleted: 1 + }, this, 'key'); + let key3 = await API.createNoteItem("", key1, this, 'key'); + + // All three items should show up with includeTrashed=1 + let response = await API.userGet( + config.userID, + "items?includeTrashed=1" + ); + let json = API.getJSONFromResponse(response); + Helpers.assertCount(3, json); + let keys = [json[0].key, json[1].key, json[2].key]; + assert.include(keys, key1); + assert.include(keys, key2); + assert.include(keys, key3); + + // ?itemKey should show the deleted item + response = await API.userGet( + config.userID, + "items?itemKey=" + key2 + "," + key3 + "&includeTrashed=1" + ); + json = API.getJSONFromResponse(response); + Helpers.assertCount(2, json); + keys = [json[0].key, json[1].key]; + assert.include(keys, key2); + assert.include(keys, key3); + + // /top should show the deleted item + response = await API.userGet( + config.userID, + "items/top?includeTrashed=1" + ); + json = API.getJSONFromResponse(response); + Helpers.assertCount(2, json); + keys = [json[0].key, json[1].key]; + assert.include(keys, key1); + assert.include(keys, key2); + }); + + it('test_should_return_409_on_missing_parent_if_parent_failed', async function () { + const collectionKey = await API.createCollection("A", {}, this, 'key'); + const version = await API.getLibraryVersion(); + const parentKey = "BDARG2AV"; + const tag = Helpers.uniqueID(300); + const item1JSON = await API.getItemTemplate("book"); + item1JSON.key = parentKey; + item1JSON.creators = [ + { + firstName: "A.", + lastName: "Nespola", + creatorType: "author" + } + ]; + item1JSON.tags = [ + { + tag: "A" + }, + { + tag: tag + } + ]; + item1JSON.collections = [collectionKey]; + const item2JSON = await API.getItemTemplate("note"); + item2JSON.parentItem = parentKey; + const response1 = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + const item3JSON = JSON.parse(response1.data); + item3JSON.parentItem = parentKey; + item3JSON.note = "Test"; + const response2 = await API.userPost( + config.userID, + "items", + JSON.stringify([item1JSON, item2JSON, item3JSON]), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assert200(response2); + const json = API.getJSONFromResponse(response2); + Helpers.assert413ForObject(json); + Helpers.assert409ForObject(json, { message: "Parent item " + parentKey + " not found", index: 1 }); + Helpers.assertEquals(parentKey, json.failed[1].data.parentItem); + Helpers.assert409ForObject(json, { message: "Parent item " + parentKey + " not found", index: 2 }); + Helpers.assertEquals(parentKey, json.failed[2].data.parentItem); + }); + + it('test_deleting_parent_item_should_delete_child_linked_file_attachment', async function () { + let json = await API.createItem('book', false, this, 'jsonData'); + let parentKey = json.key; + let parentVersion = json.version; + + json = await API.createAttachmentItem('linked_file', [], parentKey, this, 'jsonData'); + let childKey = json.key; + + let response = await API.userGet(config.userID, `items?itemKey=${parentKey},${childKey}`); + Helpers.assertNumResults(response, 2); + + response = await API.userDelete( + config.userID, + `items/${parentKey}`, + { 'If-Unmodified-Since-Version': parentVersion } + ); + Helpers.assert204(response); + + response = await API.userGet(config.userID, `items?itemKey=${parentKey},${childKey}`); + json = API.getJSONFromResponse(response); + Helpers.assertNumResults(response, 0); + }); + + it('test_patch_of_item_should_clear_trash_state', async function () { + let json = await API.createItem("book", { + deleted: true + }, this, 'json'); + + let data = [ + { + key: json.key, + version: json.version, + deleted: false + } + ]; + let response = await API.postItems(data); + json = API.getJSONFromResponse(response); + + assert.notProperty(json.successful[0].data, 'deleted'); + }); + + + /** + * Changing existing 'md5' and 'mtime' values to null was originally prevented, but some client + * versions were sending null, so now we just ignore it. + * + * At some point, we should check whether any clients are still doing this and restore the + * restriction if not. These should only be cleared on a storage purge. + */ + it('test_cannot_change_existing_storage_properties_to_null', async function () { + this.skip(); + }); + + it('testDateAddedNewItemSQL', async function () { + const objectType = 'item'; + + const dateAdded = "2013-03-03 21:33:53"; + const dateAdded8601 = "2013-03-03T21:33:53Z"; + + let itemData = { + title: "Test", + dateAdded: dateAdded + }; + let data; + if (objectType == 'item') { + data = await API.createItem("videoRecording", itemData, this, 'jsonData'); + } + + Helpers.assertEquals(dateAdded8601, data.dateAdded); + }); + + it('testDateWithoutDay', async function () { + let date = 'Sept 2012'; + let parsedDate = '2012-09'; + + let json = await API.createItem("book", { + date: date + }, this, 'jsonData'); + let key = json.key; + + let response = await API.userGet( + config.userID, + "items/" + key + ); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(date, json.data.date); + + // meta.parsedDate (JSON) + Helpers.assertEquals(parsedDate, json.meta.parsedDate); + + // zapi:parsedDate (Atom) + let xml = await API.getItem(key, this, 'atom'); + Helpers.assertEquals(parsedDate, Helpers.xpathEval(xml, '/atom:entry/zapi:parsedDate')); + }); + + it('testDateWithoutMonth', async function () { + let date = '2012'; + let parsedDate = '2012'; + + let json = await API.createItem("book", { + date: date + }, this, 'jsonData'); + let key = json.key; + + let response = await API.userGet( + config.userID, + `items/${key}` + ); + json = API.getJSONFromResponse(response); + assert.equal(date, json.data.date); + + // meta.parsedDate (JSON) + assert.equal(parsedDate, json.meta.parsedDate); + + // zapi:parsedDate (Atom) + let xml = await API.getItem(key, this, 'atom'); + assert.equal(parsedDate, Helpers.xpathEval(xml, '/atom:entry/zapi:parsedDate')); + }); + + it('test_should_allow_changing_parent_item_of_annotation_to_another_file_attachment', async function () { + let attachment1Key = await API.createAttachmentItem("imported_url", { contentType: "application/pdf" }, null, this, 'key'); + let attachment2Key = await API.createAttachmentItem("imported_url", { contentType: "application/pdf" }, null, this, 'key'); + let jsonData = await API.createAnnotationItem('highlight', {}, attachment1Key, this, 'jsonData'); + + let json = { + version: jsonData.version, + parentItem: attachment2Key + }; + let response = await API.userPatch( + config.userID, + `items/${jsonData.key}`, + JSON.stringify(json) + ); + Helpers.assert204(response); + }); + + it('test_should_reject_changing_parent_item_of_annotation_to_invalid_items', async function () { + const itemKey = await API.createItem("book", false, this, 'key'); + const linkedURLAttachmentKey = await API.createAttachmentItem("linked_url", [], itemKey, this, 'key'); + + const attachmentKey = await API.createAttachmentItem( + "imported_url", + { contentType: 'application/pdf' }, + null, + this, + 'key' + ); + const jsonData = await API.createAnnotationItem('highlight', {}, attachmentKey, this, 'jsonData'); + + // No parent + let json = { + version: jsonData.version, + parentItem: false + }; + let response = await API.userPatch( + config.userID, + "items/" + jsonData.key, + JSON.stringify(json) + ); + Helpers.assert400(response, "Annotation must have a parent item"); + + // Regular item + json = { + version: jsonData.version, + parentItem: itemKey + }; + response = await API.userPatch( + config.userID, + "items/" + jsonData.key, + JSON.stringify(json) + ); + Helpers.assert400(response, "Parent item of highlight annotation must be a PDF attachment"); + + // Linked-URL attachment + json = { + version: jsonData.version, + parentItem: linkedURLAttachmentKey + }; + response = await API.userPatch( + config.userID, + "items/" + jsonData.key, + JSON.stringify(json) + ); + Helpers.assert400(response, "Parent item of highlight annotation must be a PDF attachment"); + }); + + it('testConvertChildNoteToParentViaPatch', async function () { + let key = await API.createItem("book", { title: "Test" }, this, 'key'); + let json = await API.createNoteItem("", key, this, 'jsonData'); + json.parentItem = false; + let response = await API.userPatch( + config.userID, + `items/${json.key}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + json = (await API.getItem(json.key, this, 'json')).data; + assert.notProperty(json, 'parentItem'); + }); + + it('test_should_reject_clearing_parent_of_embedded_image_attachment', async function () { + let noteKey = await API.createNoteItem("Test", null, this, 'key'); + let response = await API.get("items/new?itemType=attachment&linkMode=embedded_image"); + let json = JSON.parse(await response.data); + json.parentItem = noteKey; + json.contentType = 'image/png'; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + let key = json.successful[0].key; + json = await API.getItem(key, this, 'json'); + + // Clear the parent item + json = { + version: json.version, + parentItem: false + }; + response = await API.userPatch( + config.userID, + `items/${key}`, + JSON.stringify(json) + ); + Helpers.assert400(response, "Cannot change parent item of embedded-image attachment"); + }); + + it('test_should_reject_parentItem_that_matches_item_key', async function () { + let response = await API.get("items/new?itemType=attachment&linkMode=imported_file"); + let json = API.getJSONFromResponse(response); + json.key = Helpers.uniqueID(); + json.version = 0; + json.parentItem = json.key; + + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]) + ); + let msg = "Item " + json.key + " cannot be a child of itself"; + // TEMP + msg += "\n\nCheck your database integrity from the Advanced → Files and Folders pane of the Zotero preferences."; + Helpers.assert400ForObject(response, { message: msg }); + }); + + it('test_num_children_and_children_on_note_with_embedded_image_attachment', async function () { + let noteKey = await API.createNoteItem("Test", null, this, 'key'); + let imageKey = await API.createAttachmentItem('embedded_image', { contentType: 'image/png' }, noteKey, this, 'key'); + let response = await API.userGet(config.userID, `items/${noteKey}`); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(1, json.meta.numChildren); + + response = await API.userGet(config.userID, `items/${noteKey}/children`); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + Helpers.assertCount(1, json); + Helpers.assertEquals(imageKey, json[0].key); + }); +}); diff --git a/tests/remote_js/test/3/keysTest.js b/tests/remote_js/test/3/keysTest.js new file mode 100644 index 00000000..9393a24e --- /dev/null +++ b/tests/remote_js/test/3/keysTest.js @@ -0,0 +1,309 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('KeysTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + // Private API + it('testKeyCreateAndModifyWithCredentials', async function () { + API.useAPIKey(""); + let name = "Test " + Helpers.uniqueID(); + + // Can't create on /users/:userID/keys with credentials + let response = await API.userPost( + config.userID, + 'keys', + JSON.stringify({ + username: config.username, + password: config.password, + name: name, + access: { + user: { + library: true + } + } + }) + ); + Helpers.assert403(response); + + // Create with credentials + response = await API.post( + 'keys', + JSON.stringify({ + username: config.username, + password: config.password, + name: name, + access: { + user: { + library: true + } + } + }), + {}, + {} + ); + Helpers.assert201(response); + let json = API.getJSONFromResponse(response); + let key = json.key; + assert.equal(json.userID, config.userID); + assert.equal(json.name, name); + assert.deepEqual({ + user: { + library: true, + files: true + } + }, json.access); + + name = "Test " + Helpers.uniqueID(); + + // Can't modify on /users/:userID/keys/:key with credentials + response = await API.userPut( + config.userID, + "keys/" + key, + JSON.stringify({ + username: config.username, + password: config.password, + name: name, + access: { + user: { + library: true + } + } + }) + ); + Helpers.assert403(response); + + // Modify with credentials + response = await API.put( + "keys/" + key, + JSON.stringify({ + username: config.username, + password: config.password, + name: name, + access: { + user: { + library: true + } + } + }) + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + key = json.key; + assert.equal(json.name, name); + + response = await API.userDelete( + config.userID, + "keys/" + key + ); + Helpers.assert204(response); + }); + + it('testKeyCreateAndDelete', async function () { + API.useAPIKey(''); + const name = 'Test ' + Helpers.uniqueID(); + + // Can't create anonymously + let response = await API.userPost( + config.userID, + 'keys', + JSON.stringify({ + name: name, + access: { + user: { library: true } + } + }) + ); + Helpers.assert403(response); + + // Create as root + response = await API.userPost( + config.userID, + 'keys', + JSON.stringify({ + name: name, + access: { + user: { library: true } + } + }), + {}, + { + username: config.rootUsername, + password: config.rootPassword + } + ); + Helpers.assert201(response); + const json = API.getJSONFromResponse(response); + const key = json.key; + assert.equal(config.username, json.username); + assert.equal(config.displayName, json.displayName); + assert.equal(name, json.name); + assert.deepEqual({ user: { library: true, files: true } }, json.access); + + // Delete anonymously (with embedded key) + response = await API.userDelete(config.userID, 'keys/current', { + 'Zotero-API-Key': key + }); + Helpers.assert204(response); + + response = await API.userGet(config.userID, 'keys/current', { + 'Zotero-API-Key': key + }); + Helpers.assert403(response); + }); + + it('testGetKeyInfoCurrent', async function () { + API.useAPIKey(""); + const response = await API.get( + 'keys/current', + { "Zotero-API-Key": config.apiKey } + ); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + assert.equal(config.apiKey, json.key); + assert.equal(config.userID, json.userID); + assert.equal(config.username, json.username); + assert.equal(config.displayName, json.displayName); + assert.property(json.access, "user"); + assert.property(json.access, "groups"); + assert.isOk(json.access.user.library); + assert.isOk(json.access.user.files); + assert.isOk(json.access.user.notes); + assert.isOk(json.access.user.write); + assert.isOk(json.access.groups.all.library); + assert.isOk(json.access.groups.all.write); + assert.notProperty(json, 'name'); + assert.notProperty(json, 'dateAdded'); + assert.notProperty(json, 'lastUsed'); + assert.notProperty(json, 'recentIPs'); + }); + + // Deprecated + it('testGetKeyInfoWithUser', async function () { + API.useAPIKey(""); + const response = await API.userGet( + config.userID, + 'keys/' + config.apiKey + ); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + assert.equal(config.apiKey, json.key); + assert.equal(config.userID, json.userID); + assert.property(json.access, "user"); + assert.property(json.access, "groups"); + assert.isOk(json.access.user.library); + assert.isOk(json.access.user.files); + assert.isOk(json.access.user.notes); + assert.isOk(json.access.user.write); + assert.isOk(json.access.groups.all.library); + assert.isOk(json.access.groups.all.write); + }); + + // Private API + it('testKeyCreateWithEmailAddress', async function () { + API.useAPIKey(""); + let name = "Test " + Helpers.uniqueID(); + let emails = [config.emailPrimary, config.emailSecondary]; + for (let i = 0; i < emails.length; i++) { + let email = emails[i]; + let data = JSON.stringify({ + username: email, + password: config.password, + name: name, + access: { + user: { + library: true + } + } + }); + let headers = { "Content-Type": "application/json" }; + let options = {}; + let response = await API.post('keys', data, headers, options); + Helpers.assert201(response); + let json = API.getJSONFromResponse(response); + assert.equal(config.userID, json.userID); + assert.equal(config.username, json.username); + assert.equal(config.displayName, json.displayName); + assert.equal(name, json.name); + assert.deepEqual({ user: { library: true, files: true } }, json.access); + } + }); + + it('testGetKeyInfoCurrentWithoutHeader', async function () { + API.useAPIKey(''); + const response = await API.get('keys/current'); + + Helpers.assert403(response); + }); + + it('testGetKeys', async function () { + // No anonymous access + API.useAPIKey(''); + let response = await API.userGet( + config.userID, + 'keys' + ); + Helpers.assert403(response); + + // No access with user's API key + API.useAPIKey(config.apiKey); + response = await API.userGet( + config.userID, + 'keys' + ); + Helpers.assert403(response); + + // Root access + response = await API.userGet( + config.userID, + 'keys', + {}, + { + username: config.rootUsername, + password: config.rootPassword, + } + ); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + assert.isArray(json, true); + assert.isAbove(json.length, 0); + assert.property(json[0], 'dateAdded'); + assert.property(json[0], 'lastUsed'); + if (config.apiURLPrefix != "http://localhost/") { + assert.property(json[0], 'recentIPs'); + } + }); + + it('testGetKeyInfoByPath', async function () { + API.useAPIKey(""); + const response = await API.get('keys/' + config.apiKey); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + assert.equal(config.apiKey, json.key); + assert.equal(config.userID, json.userID); + assert.property(json.access, 'user'); + assert.property(json.access, 'groups'); + assert.isOk(json.access.user.library); + assert.isOk(json.access.user.files); + assert.isOk(json.access.user.notes); + assert.isOk(json.access.user.write); + assert.isOk(json.access.groups.all.library); + assert.isOk(json.access.groups.all.write); + assert.notProperty(json, 'name'); + assert.notProperty(json, 'dateAdded'); + assert.notProperty(json, 'lastUsed'); + assert.notProperty(json, 'recentIPs'); + }); +}); diff --git a/tests/remote_js/test/3/mappingsTest.js b/tests/remote_js/test/3/mappingsTest.js new file mode 100644 index 00000000..c3341dd8 --- /dev/null +++ b/tests/remote_js/test/3/mappingsTest.js @@ -0,0 +1,159 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('MappingsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + it('testLocale', async function () { + let response = await API.get("itemTypes?locale=fr-FR"); + Helpers.assert200(response); + let json = JSON.parse(response.data); + let o; + for (let i = 0; i < json.length; i++) { + if (json[i].itemType == 'book') { + o = json[i]; + break; + } + } + Helpers.assertEquals('Livre', o.localized); + }); + + it('test_should_return_fields_for_note_annotations', async function () { + let response = await API.get("items/new?itemType=annotation&annotationType=highlight"); + let json = API.getJSONFromResponse(response); + assert.property(json, 'annotationText'); + Helpers.assertEquals(json.annotationText, ''); + }); + + it('test_should_reject_unknown_annotation_type', async function () { + let response = await API.get("items/new?itemType=annotation&annotationType=foo", { "Content-Type": "application/json" }); + Helpers.assert400(response); + }); + + it('testNewItem', async function () { + let response = await API.get("items/new?itemType=invalidItemType"); + Helpers.assert400(response); + + response = await API.get("items/new?itemType=book"); + Helpers.assert200(response); + Helpers.assertContentType(response, 'application/json'); + let json = JSON.parse(response.data); + Helpers.assertEquals('book', json.itemType); + }); + + it('testComputerProgramVersion', async function () { + let response = await API.get("items/new?itemType=computerProgram"); + Helpers.assert200(response); + let json = JSON.parse(response.data); + + assert.property(json, 'versionNumber'); + assert.notProperty(json, 'version'); + + response = await API.get("itemTypeFields?itemType=computerProgram"); + Helpers.assert200(response); + json = JSON.parse(response.data); + + let fields = json.map((val) => { + return val.field; + }); + + assert.include(fields, 'versionNumber'); + assert.notInclude(fields, 'version'); + }); + + it('test_should_return_fields_for_highlight_annotations', async function () { + const response = await API.get("items/new?itemType=annotation&annotationType=highlight"); + const json = API.getJSONFromResponse(response); + assert.property(json, 'annotationText'); + assert.equal(json.annotationText, ''); + }); + + it('test_should_return_fields_for_all_annotation_types', async function () { + for (let type of ['highlight', 'note', 'image']) { + const response = await API.get(`items/new?itemType=annotation&annotationType=${type}`); + const json = API.getJSONFromResponse(response); + + assert.property(json, 'annotationComment'); + Helpers.assertEquals('', json.annotationComment); + Helpers.assertEquals('', json.annotationColor); + Helpers.assertEquals('', json.annotationPageLabel); + Helpers.assertEquals('00000|000000|00000', json.annotationSortIndex); + assert.property(json, 'annotationPosition'); + Helpers.assertEquals(0, json.annotationPosition.pageIndex); + assert.isArray(json.annotationPosition.rects); + assert.notProperty(json, 'collections'); + assert.notProperty(json, 'relations'); + } + }); + + it('test_should_reject_missing_annotation_type', async function () { + let response = await API.get("items/new?itemType=annotation"); + Helpers.assert400(response); + }); + + it('test_should_return_attachment_fields', async function () { + let response = await API.get("items/new?itemType=attachment&linkMode=linked_url"); + let json = JSON.parse(response.data); + assert.equal(json.url, ''); + assert.notProperty(json, 'filename'); + assert.notProperty(json, 'path'); + + response = await API.get("items/new?itemType=attachment&linkMode=linked_file"); + json = JSON.parse(response.data); + assert.equal(json.path, ''); + assert.notProperty(json, 'filename'); + assert.notProperty(json, 'url'); + + response = await API.get("items/new?itemType=attachment&linkMode=imported_url"); + json = JSON.parse(response.data); + assert.equal(json.filename, ''); + assert.equal(json.url, ''); + assert.notProperty(json, 'path'); + + response = await API.get("items/new?itemType=attachment&linkMode=imported_file"); + json = JSON.parse(response.data); + assert.equal(json.filename, ''); + assert.notProperty(json, 'path'); + assert.notProperty(json, 'url'); + + response = await API.get("items/new?itemType=attachment&linkMode=embedded_image"); + json = JSON.parse(response.data); + assert.notProperty(json, 'title'); + assert.notProperty(json, 'url'); + assert.notProperty(json, 'accessDate'); + assert.notProperty(json, 'tags'); + assert.notProperty(json, 'collections'); + assert.notProperty(json, 'relations'); + assert.notProperty(json, 'note'); + assert.notProperty(json, 'charset'); + assert.notProperty(json, 'path'); + }); + + it('test_should_return_fields_for_image_annotations', async function () { + let response = await API.get('items/new?itemType=annotation&annotationType=image'); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(0, json.annotationPosition.width); + Helpers.assertEquals(0, json.annotationPosition.height); + }); + + it('test_should_return_a_note_template', async function () { + let response = await API.get("items/new?itemType=note"); + Helpers.assert200(response); + Helpers.assertContentType(response, 'application/json'); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals('note', json.itemType); + assert.property(json, 'note'); + }); +}); diff --git a/tests/remote_js/test/3/noteTest.js b/tests/remote_js/test/3/noteTest.js new file mode 100644 index 00000000..ed8a50f8 --- /dev/null +++ b/tests/remote_js/test/3/noteTest.js @@ -0,0 +1,152 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('NoteTests', function () { + this.timeout(config.timeout); + + let content, json; + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + this.beforeEach(async function() { + content = "1234567890".repeat(50001); + json = await API.getItemTemplate('note'); + json.note = content; + }); + + it('testSaveHTML', async function () { + const content = '

Foo & Bar

'; + const json = await API.createNoteItem(content, false, this, 'json'); + Helpers.assertEquals(content, json.data.note); + }); + + it('testNoteTooLong', async function () { + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert413ForObject( + response, + "Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long" + ); + }); + + it('testNoteTooLongBlankFirstLines', async function () { + json.note = " \n \n" + content; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + + Helpers.assert413ForObject( + response, + "Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long" + ); + }); + + it('testSaveUnchangedSanitizedNote', async function () { + let json = await API.createNoteItem('Foo', false, this, 'json'); + let response = await API.postItem(json.data, { "Content-Type": "application/json" }); + json = API.getJSONFromResponse(response); + let unchanged = json.unchanged; + assert.property(unchanged, 0); + }); + + it('testNoteTooLongBlankFirstLinesHTML', async function () { + json.note = "\n

 

\n

 

\n" + content; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + + Helpers.assert413ForObject( + response, + "Note '1234567890123456789012345678901234567890123456789012345678901234567890123...' too long" + ); + }); + + it('test_utf8mb4_note', async function () { + let note = "

🐻

"; + json.note = note; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + + Helpers.assert200ForObject(response); + + let jsonResponse = API.getJSONFromResponse(response); + let data = jsonResponse.successful[0].data; + assert.equal(note, data.note); + }); + + it('testNoteTooLongWithinHTMLTags', async function () { + json.note = " \n

"; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert413ForObject( + response, + "Note '<p><!-- 1234567890123456789012345678901234567890123456789012345678901234...' too long" + ); + }); + + it('testNoteTooLongTitlePlusNewlines', async function () { + json.note = `Full Text:\n\n${content}`; + let response = await API.userPost( + config.userID, + 'items', + JSON.stringify([json]), + { 'Content-Type': 'application/json' } + ); + Helpers.assert413ForObject( + response, + "Note 'Full Text: 1234567890123456789012345678901234567890123456789012345678901234567...' too long" + ); + }); + + it('test_should_allow_zotero_links_in_notes', async function () { + let json = await API.createNoteItem('

Test

', false, this, 'json'); + + const val = '

Test

'; + json.data.note = val; + + let response = await API.postItem(json.data); + let jsonResp = API.getJSONFromResponse(response); + Helpers.assertEquals(val, jsonResp.successful[0].data.note); + }); + + it('testSaveHTMLAtom', async function () { + let content = '

Foo & Bar

'; + let xml = await API.createNoteItem(content, false, this, 'atom'); + let contentXml = xml.getElementsByTagName('content')[0]; + const tempNode = xml.createElement("textarea"); + const htmlNote = JSON.parse(contentXml.innerHTML).note; + tempNode.innerHTML = htmlNote; + assert.equal(tempNode.textContent, content); + }); +}); diff --git a/tests/remote_js/test/3/notificationTest.js b/tests/remote_js/test/3/notificationTest.js new file mode 100644 index 00000000..4d91c74e --- /dev/null +++ b/tests/remote_js/test/3/notificationTest.js @@ -0,0 +1,486 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After, resetGroups } = require("../shared.js"); + +describe('NotificationTests', function () { + this.timeout(0); + + before(async function () { + await API3Before(); + await resetGroups(); + }); + + after(async function () { + await API3After(); + }); + beforeEach(async function () { + API.useAPIKey(config.apiKey); + }); + + it('testModifyItemNotification', async function () { + let json = await API.createItem("book", false, this, 'jsonData'); + json.title = 'test'; + let response = await API.userPut( + config.userID, + `items/${json.key}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + let version = parseInt(response.headers['last-modified-version'][0]); + Helpers.assertNotificationCount(1, response); + Helpers.assertHasNotification({ + event: 'topicUpdated', + topic: `/users/${config.userID}`, + version: version + }, response); + }); + + /** + * Grant an API key access to a group + */ + it('testKeyAddLibraryNotification', async function () { + API.useAPIKey(""); + const name = "Test " + Helpers.uniqueID(); + const json = { + name: name, + access: { + user: { + library: true + } + } + }; + + const response = await API.superPost( + 'users/' + config.userID + '/keys?showid=1', + JSON.stringify(json) + ); + + Helpers.assert201(response); + const jsonFromResponse = API.getJSONFromResponse(response); + const apiKey = jsonFromResponse.key; + const apiKeyID = jsonFromResponse.id; + + try { + json.access.groups = {}; + // Add a group to the key, which should trigger topicAdded + json.access.groups[config.ownedPrivateGroupID] = { + library: true, + write: true + }; + + const response2 = await API.superPut( + "keys/" + apiKey, + JSON.stringify(json) + ); + Helpers.assert200(response2); + + Helpers.assertNotificationCount(1, response2); + Helpers.assertHasNotification({ + event: 'topicAdded', + apiKeyID: String(apiKeyID), + topic: '/groups/' + config.ownedPrivateGroupID + }, response2); + } + // Clean up + finally { + await API.superDelete("keys/" + apiKey); + } + }); + + it('testNewItemNotification', async function () { + const response = await API.createItem("book", false, this, 'response'); + const version = API.getJSONFromResponse(response).successful[0].version; + Helpers.assertNotificationCount(1, response); + Helpers.assertHasNotification({ + event: 'topicUpdated', + topic: '/users/' + config.userID, + version: version + }, response); + }); + + + it('testKeyCreateNotification', async function () { + API.useAPIKey(""); + let name = "Test " + Helpers.uniqueID(); + let response = await API.superPost( + 'users/' + config.userID + '/keys', + JSON.stringify({ + name: name, + access: { user: { library: true } } + }) + ); + try { + // No notification when creating a new key + Helpers.assertNotificationCount(0, response); + } + finally { + let json = API.getJSONFromResponse(response); + let key = json.key; + await API.userDelete( + config.userID, + "keys/" + key, + { "Content-Type": "application/json" } + ); + } + }); + + /** + * Create and delete group owned by user + */ + it('testAddDeleteOwnedGroupNotification', async function () { + API.useAPIKey(""); + const json = await createKeyWithAllGroupAccess(config.userID); + const apiKey = json.key; + + try { + const allGroupsKeys = await getKeysWithAllGroupAccess(config.userID); + // Create new group owned by user + const response = await createGroup(config.userID); + const xml = API.getXMLFromResponse(response); + const groupID = parseInt(Helpers.xpathEval(xml, "/atom:entry/zapi:groupID")); + + try { + Helpers.assertNotificationCount(Object.keys(allGroupsKeys).length, response); + await Promise.all(allGroupsKeys.map(async function (key) { + const response2 = await API.superGet(`keys/${key}?showid=1`); + const json2 = API.getJSONFromResponse(response2); + Helpers.assertHasNotification({ + event: "topicAdded", + apiKeyID: String(json2.id), + topic: `/groups/${groupID}` + }, response); + })); + } + // Delete group + finally { + const response = await API.superDelete(`groups/${groupID}`); + Helpers.assert204(response); + Helpers.assertNotificationCount(1, response); + Helpers.assertHasNotification({ + event: "topicDeleted", + topic: `/groups/${groupID}` + }, response); + } + } + // Delete key + finally { + const response = await API.superDelete(`keys/${apiKey}`); + try { + Helpers.assert204(response); + } + catch (e) { + console.log(e); + } + } + }); + + it('testDeleteItemNotification', async function () { + let json = await API.createItem("book", false, this, 'json'); + let response = await API.userDelete( + config.userID, + `items/${json.key}`, + { + "If-Unmodified-Since-Version": json.version + } + ); + let version = parseInt(response.headers['last-modified-version'][0]); + Helpers.assertNotificationCount(1, response); + Helpers.assertHasNotification({ + event: 'topicUpdated', + topic: `/users/${config.userID}`, + version: version + }, response); + }); + + /** + * Revoke access for a group from an API key that has access to all groups + */ + it('testKeyRemoveLibraryFromAllGroupsNotification', async function () { + API.useAPIKey(""); + const removedGroup = config.ownedPrivateGroupID; + const json = await createKeyWithAllGroupAccess(config.userID); + const apiKey = json.key; + const apiKeyID = json.id; + try { + // Get list of available groups + API.useAPIKey(apiKey); + const response = await API.userGet(config.userID, 'groups'); + let groupIDs = API.getJSONFromResponse(response).map(group => group.id); + + // Remove one group, and replace access array with new set + groupIDs = groupIDs.filter(groupID => groupID !== removedGroup); + delete json.access.groups.all; + for (let groupID of groupIDs) { + json.access.groups[groupID] = {}; + json.access.groups[groupID].library = true; + } + + // Post new JSON, which should trigger topicRemoved for the removed group + API.useAPIKey(""); + const putResponse = await API.superPut(`keys/${apiKey}`, JSON.stringify(json)); + Helpers.assert200(putResponse); + Helpers.assertNotificationCount(1, putResponse); + + Helpers.assertHasNotification({ + event: "topicRemoved", + apiKeyID: String(apiKeyID), + topic: `/groups/${removedGroup}` + }, putResponse); + } + finally { + await API.superDelete(`keys/${apiKey}`); + } + }); + + it('Create and delete group owned by user', async function () { + // Dummy test function, not related to the code above. + // Just here so that the class doesn't break the syntax of the original phpunit file + // and can be tested using mocha/chai + assert(true); + }); + + async function createKey(userID, access) { + let name = "Test " + Math.random().toString(36).substring(2); + let json = { + name: name, + access: access + }; + const response = await API.superPost( + "users/" + userID + "/keys?showid=1", + JSON.stringify(json) + ); + assert.equal(response.status, 201); + json = API.getJSONFromResponse(response); + return json; + } + + async function createKeyWithAllGroupAccess(userID) { + return createKey(userID, { + user: { + library: true + }, + groups: { + all: { + library: true + } + } + }); + } + + async function createGroup(ownerID) { + // Create new group owned by another + let xml = ''; + const response = await API.superPost( + 'groups', + xml + ); + assert.equal(response.status, 201); + return response; + } + + async function getKeysWithAllGroupAccess(userID) { + const response = await API.superGet("users/" + userID + "/keys"); + assert.equal(response.status, 200); + const json = API.getJSONFromResponse(response); + return json.filter(keyObj => keyObj.access.groups.all.library).map(keyObj => keyObj.key); + } + + + it('testAddRemoveGroupMemberNotification', async function () { + API.useAPIKey(""); + let json = await createKeyWithAllGroupAccess(config.userID); + let apiKey = json.key; + + try { + // Get all keys with access to all groups + let allGroupsKeys = await getKeysWithAllGroupAccess(config.userID); + + // Create group owned by another user + let response = await createGroup(config.userID2); + let xml = API.getXMLFromResponse(response); + let groupID = parseInt(Helpers.xpathEval(xml, "/atom:entry/zapi:groupID")); + + try { + // Add user to group + response = await API.superPost( + "groups/" + groupID + "/users", + '', + { "Content-Type": "application/xml" } + ); + Helpers.assert200(response); + Helpers.assertNotificationCount(Object.keys(allGroupsKeys).length, response); + for (let key of allGroupsKeys) { + let response2 = await API.superGet("keys/" + key + "?showid=1"); + let json2 = API.getJSONFromResponse(response2); + Helpers.assertHasNotification({ + event: 'topicAdded', + apiKeyID: String(json2.id), + topic: '/groups/' + groupID + }, response); + } + + // Remove user from group + response = await API.superDelete("groups/" + groupID + "/users/" + config.userID); + Helpers.assert204(response); + Helpers.assertNotificationCount(Object.keys(allGroupsKeys).length, response); + for (let key of allGroupsKeys) { + let response2 = await API.superGet("keys/" + key + "?showid=1"); + let json2 = API.getJSONFromResponse(response2); + Helpers.assertHasNotification({ + event: 'topicRemoved', + apiKeyID: String(json2.id), + topic: '/groups/' + groupID + }, response); + } + } + // Delete group + finally { + response = await API.superDelete("groups/" + groupID); + Helpers.assert204(response); + Helpers.assertNotificationCount(1, response); + Helpers.assertHasNotification({ + event: 'topicDeleted', + topic: '/groups/' + groupID + }, response); + } + } + // Delete key + finally { + let response = await API.superDelete("keys/" + apiKey); + try { + Helpers.assert204(response); + } + catch (e) { + console.log(e); + } + } + }); + + + it('testKeyAddAllGroupsToNoneNotification', async function () { + API.useAPIKey(""); + const json = await createKey(config.userID, + { user: { library: true } }, + ); + const apiKey = json.key; + const apiKeyId = json.id; + + try { + // Get list of available groups + const response = await API.superGet(`users/${config.userID}/groups`); + const groupIds = API.getJSONFromResponse(response).map(group => group.id); + json.access.groups = []; + // Add all groups to the key, which should trigger topicAdded for each groups + json.access.groups[0] = { library: true }; + const putResponse = await API.superPut(`keys/${apiKey}`, JSON.stringify(json)); + Helpers.assert200(putResponse); + + Helpers.assertNotificationCount(groupIds.length, putResponse); + + for (const groupID of groupIds) { + Helpers.assertHasNotification( + { + event: "topicAdded", + apiKeyID: String(apiKeyId), + topic: `/groups/${groupID}`, + }, + putResponse + ); + } + } + finally { + await API.superDelete(`keys/${apiKey}`); + } + }); + + it('testKeyRemoveLibraryNotification', async function () { + API.useAPIKey(""); + let json = await createKey(config.userID, { + user: { + library: true + }, + groups: { + [config.ownedPrivateGroupID]: { + library: true + } + } + }); + const apiKey = json.key; + const apiKeyID = json.id; + + try { + // Remove group from the key, which should trigger topicRemoved + delete json.access.groups; + const response = await API.superPut( + `keys/${apiKey}`, + JSON.stringify(json) + ); + Helpers.assert200(response); + + Helpers.assertNotificationCount(1, response); + Helpers.assertHasNotification({ + event: 'topicRemoved', + apiKeyID: String(apiKeyID), + topic: `/groups/${config.ownedPrivateGroupID}` + }, response); + } + finally { + await API.superDelete(`keys/${apiKey}`); + } + }); + + /** + * Grant access to all groups to an API key that has access to a single group + */ + + + it('testKeyAddAllGroupsToOneNotification', async function () { + API.useAPIKey(''); + + let json = await createKey(config.userID, { + user: { + library: true + }, + groups: { + [config.ownedPrivateGroupID]: { + library: true + } + } + }); + let apiKey = json.key; + let apiKeyID = json.id; + + try { + // Get list of available groups + let response = await API.superGet(`users/${config.userID}/groups`); + let groupIDs = API.getJSONFromResponse(response).map(group => group.id); + // Remove group that already had access + groupIDs = groupIDs.filter(groupID => groupID !== config.ownedPrivateGroupID); + + // Add all groups to the key, which should trigger topicAdded for each new group + // but not the group that was previously accessible + delete json.access.groups[config.ownedPrivateGroupID]; + json.access.groups.all = { + library: true + }; + response = await API.superPut(`keys/${apiKey}`, JSON.stringify(json)); + assert.equal(200, response.status); + + await Helpers.assertNotificationCount(groupIDs.length, response); + for (let groupID of groupIDs) { + Helpers.assertHasNotification({ + event: 'topicAdded', + apiKeyID: String(apiKeyID), + topic: `/groups/${groupID}` + }, response); + } + } + // Clean up + finally { + await API.superDelete(`keys/${apiKey}`); + } + }); +}); diff --git a/tests/remote_js/test/3/objectTest.js b/tests/remote_js/test/3/objectTest.js new file mode 100644 index 00000000..8092414f --- /dev/null +++ b/tests/remote_js/test/3/objectTest.js @@ -0,0 +1,792 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('ObjectTests', function () { + this.timeout(config.timeout); + let types = ['collection', 'search', 'item']; + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + beforeEach(async function () { + await API.userClear(config.userID); + }); + + afterEach(async function () { + await API.userClear(config.userID); + }); + + const _testMultiObjectGet = async (objectType = 'collection') => { + const objectNamePlural = API.getPluralObjectType(objectType); + const keyProp = `${objectType}Key`; + + const keys = []; + switch (objectType) { + case 'collection': + keys.push(await API.createCollection("Name", false, true, 'key')); + keys.push(await API.createCollection("Name", false, true, 'key')); + await API.createCollection("Name", false, true, 'key'); + break; + + case 'item': + keys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + keys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + await API.createItem("book", { title: "Title" }, true, 'key'); + break; + + case 'search': + keys.push(await API.createSearch("Name", 'default', true, 'key')); + keys.push(await API.createSearch("Name", 'default', true, 'key')); + await API.createSearch("Name", 'default', true, 'key'); + break; + } + + // HEAD request should include Total-Results + let response = await API.userHead( + config.userID, + `${objectNamePlural}?key=${config.apiKey}&${keyProp}=${keys.join(',')}` + ); + Helpers.assert200(response); + Helpers.assertTotalResults(response, keys.length); + + + response = await API.userGet( + config.userID, + `${objectNamePlural}?${keyProp}=${keys.join(',')}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, keys.length); + + // Trailing comma in itemKey parameter + response = await API.userGet( + config.userID, + `${objectNamePlural}?${keyProp}=${keys.join(',')},` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, keys.length); + }; + + const _testSingleObjectDelete = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let json; + switch (objectType) { + case 'collection': + json = await API.createCollection('Name', false, true, 'json'); + break; + case 'item': + json = await API.createItem('book', { title: 'Title' }, true, 'json'); + break; + case 'search': + json = await API.createSearch('Name', 'default', true, 'json'); + break; + } + + const objectKey = json.key; + const objectVersion = json.version; + + const responseDelete = await API.userDelete( + config.userID, + `${objectTypePlural}/${objectKey}`, + { 'If-Unmodified-Since-Version': objectVersion } + ); + Helpers.assertStatusCode(responseDelete, 204); + + const responseGet = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}` + ); + Helpers.assertStatusCode(responseGet, 404); + }; + + const _testMultiObjectDelete = async (objectType) => { + const objectTypePlural = await API.getPluralObjectType(objectType); + const keyProp = `${objectType}Key`; + + const deleteKeys = []; + const keepKeys = []; + switch (objectType) { + case 'collection': + deleteKeys.push(await API.createCollection("Name", false, true, 'key')); + deleteKeys.push(await API.createCollection("Name", false, true, 'key')); + keepKeys.push(await API.createCollection("Name", false, true, 'key')); + break; + + case 'item': + deleteKeys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + deleteKeys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + keepKeys.push(await API.createItem("book", { title: "Title" }, true, 'key')); + break; + + case 'search': + deleteKeys.push(await API.createSearch("Name", 'default', true, 'key')); + deleteKeys.push(await API.createSearch("Name", 'default', true, 'key')); + keepKeys.push(await API.createSearch("Name", 'default', true, 'key')); + break; + } + + let response = await API.userGet(config.userID, `${objectTypePlural}`); + Helpers.assertNumResults(response, deleteKeys.length + keepKeys.length); + + let libraryVersion = response.headers["last-modified-version"]; + + response = await API.userDelete(config.userID, + `${objectTypePlural}?${keyProp}=${deleteKeys.join(',')}`, + { "If-Unmodified-Since-Version": libraryVersion } + ); + Helpers.assertStatusCode(response, 204); + libraryVersion = response.headers["last-modified-version"]; + response = await API.userGet(config.userID, `${objectTypePlural}`); + Helpers.assertNumResults(response, keepKeys.length); + + response = await API.userGet(config.userID, `${objectTypePlural}?${keyProp}=${keepKeys.join(',')}`); + Helpers.assertNumResults(response, keepKeys.length); + + // Add trailing comma to itemKey param, to test key parsing + response = await API.userDelete(config.userID, + `${objectTypePlural}?${keyProp}=${keepKeys.join(',')},`, + { "If-Unmodified-Since-Version": libraryVersion }); + Helpers.assertStatusCode(response, 204); + + response = await API.userGet(config.userID, `${objectTypePlural}?`); + Helpers.assertNumResults(response, 0); + }; + + const _testPartialWriteFailure = async (objectType) => { + let conditions = []; + let json1 = { name: "Test" }; + let json2 = { name: "1234567890".repeat(6554) }; + let json3 = { name: "Test" }; + switch (objectType) { + case 'collection': + json1 = { name: "Test" }; + json2 = { name: "1234567890".repeat(6554) }; + json3 = { name: "Test" }; + break; + case 'item': + json1 = await API.getItemTemplate('book'); + json2 = { ...json1 }; + json3 = { ...json1 }; + json2.title = "1234567890".repeat(6554); + break; + case 'search': + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'value' + } + ]; + json1 = { name: "Test", conditions: conditions }; + json2 = { name: "1234567890".repeat(6554), conditions: conditions }; + json3 = { name: "Test", conditions: conditions }; + break; + } + + const response = await API.userPost( + config.userID, + `${API.getPluralObjectType(objectType)}?`, + JSON.stringify([json1, json2, json3]), + { "Content-Type": "application/json" }); + + Helpers.assertStatusCode(response, 200); + let successKeys = await API.getSuccessKeysFrom(response); + + Helpers.assert200ForObject(response, { index: 0 }); + Helpers.assert413ForObject(response, { index: 1 }); + Helpers.assert200ForObject(response, { index: 2 }); + + const responseKeys = await API.userGet( + config.userID, + `${API.getPluralObjectType(objectType)}?format=keys&key=${config.apiKey}` + ); + + Helpers.assertStatusCode(responseKeys, 200); + const keys = responseKeys.data.trim().split("\n"); + + assert.lengthOf(keys, 2); + successKeys.forEach((key) => { + assert.include(keys, key); + }); + }; + + const _testPartialWriteFailureWithUnchanged = async (objectType) => { + await API.userClear(config.userID); + let objectTypePlural = API.getPluralObjectType(objectType); + + let json1; + let json2; + let json3; + let conditions = []; + + switch (objectType) { + case 'collection': + json1 = await API.createCollection('Test', false, true, 'jsonData'); + json2 = { name: "1234567890".repeat(6554) }; + json3 = { name: 'Test' }; + break; + + case 'item': + json1 = await API.createItem('book', { title: 'Title' }, true, 'jsonData'); + json2 = await API.getItemTemplate('book'); + json3 = { ...json2 }; + json2.title = "1234567890".repeat(6554); + break; + + case 'search': + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'value' + } + ]; + json1 = await API.createSearch('Name', conditions, true, 'jsonData'); + json2 = { + name: "1234567890".repeat(6554), + conditions + }; + json3 = { + name: 'Test', + conditions + }; + break; + } + + let response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json1, json2, json3]), + { 'Content-Type': 'application/json' } + ); + + Helpers.assertStatusCode(response, 200); + let successKeys = API.getSuccessfulKeysFromResponse(response); + + Helpers.assertStatusForObject(response, 'unchanged', 0); + Helpers.assert413ForObject(response, { index: 1 }); + Helpers.assert200ForObject(response, { index: 2 }); + + + response = await API.userGet(config.userID, + `${objectTypePlural}?format=keys&key=${config.apiKey}`); + Helpers.assertStatusCode(response, 200); + let keys = response.data.trim().split('\n'); + + assert.lengthOf(keys, 2); + + for (let key of successKeys) { + assert.include(keys, key); + } + }; + + const _testMultiObjectWriteInvalidObject = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify({ foo: "bar" }), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 400, "Uploaded data must be a JSON array"); + + response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([[], ""]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: `Invalid value for index 0 in uploaded data; expected JSON ${objectType} object`, index: 0 }); + Helpers.assert400ForObject(response, { message: `Invalid value for index 1 in uploaded data; expected JSON ${objectType} object`, index: 1 }); + }; + + it('testMultiObjectGet', async function () { + await _testMultiObjectGet('collection'); + await _testMultiObjectGet('item'); + await _testMultiObjectGet('search'); + }); + it('testSingleObjectDelete', async function () { + await _testSingleObjectDelete('collection'); + await _testSingleObjectDelete('item'); + await _testSingleObjectDelete('search'); + }); + it('testMultiObjectDelete', async function () { + await _testMultiObjectDelete('collection'); + await _testMultiObjectDelete('item'); + await _testMultiObjectDelete('search'); + }); + it('testPartialWriteFailure', async function () { + _testPartialWriteFailure('collection'); + _testPartialWriteFailure('item'); + _testPartialWriteFailure('search'); + }); + it('testPartialWriteFailureWithUnchanged', async function () { + await _testPartialWriteFailureWithUnchanged('collection'); + await _testPartialWriteFailureWithUnchanged('item'); + await _testPartialWriteFailureWithUnchanged('search'); + }); + + it('testMultiObjectWriteInvalidObject', async function () { + await _testMultiObjectWriteInvalidObject('collection'); + await _testMultiObjectWriteInvalidObject('item'); + await _testMultiObjectWriteInvalidObject('search'); + }); + + it('testDeleted', async function () { + // Create objects + const objectKeys = {}; + objectKeys.tag = ["foo", "bar"]; + + objectKeys.collection = []; + objectKeys.collection.push(await API.createCollection("Name", false, true, 'key')); + objectKeys.collection.push(await API.createCollection("Name", false, true, 'key')); + objectKeys.collection.push(await API.createCollection("Name", false, true, 'key')); + + objectKeys.item = []; + objectKeys.item.push(await API.createItem("book", { title: "Title", tags: objectKeys.tag.map(tag => ({ tag })) }, true, 'key')); + objectKeys.item.push(await API.createItem("book", { title: "Title" }, true, 'key')); + objectKeys.item.push(await API.createItem("book", { title: "Title" }, true, 'key')); + + objectKeys.search = []; + objectKeys.search.push(await API.createSearch("Name", 'default', true, 'key')); + objectKeys.search.push(await API.createSearch("Name", 'default', true, 'key')); + objectKeys.search.push(await API.createSearch("Name", 'default', true, 'key')); + + // Get library version + let response = await API.userGet(config.userID, "items?key=" + config.apiKey + "&format=keys&limit=1"); + let libraryVersion1 = response.headers["last-modified-version"][0]; + + const testDelete = async (objectType, libraryVersion, url) => { + const objectTypePlural = await API.getPluralObjectType(objectType); + const response = await API.userDelete(config.userID, + `${objectTypePlural}?key=${config.apiKey}${url}`, + { "If-Unmodified-Since-Version": libraryVersion }); + Helpers.assertStatusCode(response, 204); + return response.headers["last-modified-version"][0]; + }; + + let tempLibraryVersion = await testDelete('collection', libraryVersion1, "&collectionKey=" + objectKeys.collection[0]); + tempLibraryVersion = await testDelete('item', tempLibraryVersion, "&itemKey=" + objectKeys.item[0]); + tempLibraryVersion = await testDelete('search', tempLibraryVersion, "&searchKey=" + objectKeys.search[0]); + let libraryVersion2 = tempLibraryVersion; + + // /deleted without 'since' should be an error + response = await API.userGet( + config.userID, + "deleted?key=" + config.apiKey + ); + Helpers.assert400(response); + + // Delete second and third objects + tempLibraryVersion = await testDelete('collection', tempLibraryVersion, "&collectionKey=" + objectKeys.collection.slice(1).join(',')); + tempLibraryVersion = await testDelete('item', tempLibraryVersion, "&itemKey=" + objectKeys.item.slice(1).join(',')); + let libraryVersion3 = await testDelete('search', tempLibraryVersion, "&searchKey=" + objectKeys.search.slice(1).join(',')); + + // Request all deleted objects + response = await API.userGet(config.userID, "deleted?key=" + config.apiKey + "&since=" + libraryVersion1); + Helpers.assertStatusCode(response, 200); + let json = JSON.parse(response.data); + let version = response.headers["last-modified-version"][0]; + assert.isNotNull(version); + assert.equal(response.headers["content-type"][0], "application/json"); + + const assertEquivalent = (response, equivalentTo) => { + Helpers.assert200(response); + assert.equal(response.data, equivalentTo.data); + assert.deepEqual(response.headers['last-modified-version'], equivalentTo.headers['last-modified-version']); + assert.deepEqual(response.headers['content-type'], equivalentTo.headers['content-type']); + }; + + // Make sure 'newer' is equivalent + let responseAlt = await API.userGet( + config.userID, + "deleted?key=" + config.apiKey + "&newer=" + libraryVersion1 + ); + assertEquivalent(responseAlt, response); + + // Make sure 'since=0' is equivalent + responseAlt = await API.userGet( + config.userID, + "deleted?key=" + config.apiKey + "&since=0" + ); + assertEquivalent(responseAlt, response); + + // Make sure 'newer=0' is equivalent + responseAlt = await API.userGet( + config.userID, + "deleted?key=" + config.apiKey + "&newer=0" + ); + assertEquivalent(responseAlt, response); + + // Verify keys + const verifyKeys = async (json, objectType, objectKeys) => { + const objectTypePlural = await API.getPluralObjectType(objectType); + assert.containsAllKeys(json, [objectTypePlural]); + assert.lengthOf(json[objectTypePlural], objectKeys.length); + for (let key of objectKeys) { + assert.include(json[objectTypePlural], key); + } + }; + await verifyKeys(json, 'collection', objectKeys.collection); + await verifyKeys(json, 'item', objectKeys.item); + await verifyKeys(json, 'search', objectKeys.search); + // Tags aren't deleted by removing from items + await verifyKeys(json, 'tag', []); + + // Request second and third deleted objects + response = await API.userGet( + config.userID, + `deleted?key=${config.apiKey}&since=${libraryVersion2}` + ); + Helpers.assertStatusCode(response, 200); + json = JSON.parse(response.data); + version = response.headers["last-modified-version"][0]; + assert.isNotNull(version); + assert.equal(response.headers["content-type"][0], "application/json"); + + responseAlt = await API.userGet( + config.userID, + `deleted?key=${config.apiKey}&newer=${libraryVersion2}` + ); + assertEquivalent(responseAlt, response); + + await verifyKeys(json, 'collection', objectKeys.collection.slice(1)); + await verifyKeys(json, 'item', objectKeys.item.slice(1)); + await verifyKeys(json, 'search', objectKeys.search.slice(1)); + // Tags aren't deleted by removing from items + await verifyKeys(json, 'tag', []); + + // Explicit tag deletion + response = await API.userDelete( + config.userID, + `tags?key=${config.apiKey}&tag=${objectKeys.tag.join('%20||%20')}`, + { "If-Unmodified-Since-Version": libraryVersion3 } + ); + Helpers.assertStatusCode(response, 204); + + // Verify deleted tags + response = await API.userGet( + config.userID, + `deleted?key=${config.apiKey}&newer=${libraryVersion3}` + ); + Helpers.assertStatusCode(response, 200); + json = JSON.parse(response.data); + await verifyKeys(json, 'tag', objectKeys.tag); + }); + + + it('test_patch_with_deleted_should_clear_trash_state', async function () { + for (let type of types) { + const dataObj = { + deleted: true, + }; + const json = await API.createDataObject(type, dataObj, this); + // TODO: Change to true in APIv4 + if (type === 'item') { + assert.equal(json.data.deleted, 1); + } + else { + assert.ok(json.data.deleted); + } + const data = [ + { + key: json.key, + version: json.version, + deleted: false + } + ]; + const response = await API.postObjects(type, data); + const jsonResponse = API.getJSONFromResponse(response); + assert.notProperty(jsonResponse.successful[0].data, 'deleted'); + } + }); + + const _testResponseJSONPut = async (objectType) => { + const objectPlural = API.getPluralObjectType(objectType); + let json1, conditions; + + switch (objectType) { + case 'collection': + json1 = { name: 'Test 1' }; + break; + + case 'item': + json1 = await API.getItemTemplate('book'); + json1.title = 'Test 1'; + break; + + case 'search': + conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'value' + } + ]; + json1 = { name: 'Test 1', conditions }; + break; + } + + let response = await API.userPost( + config.userID, + `${objectPlural}`, + JSON.stringify([json1]), + { 'Content-Type': 'application/json' } + ); + + Helpers.assert200(response); + + let json = API.getJSONFromResponse(response); + Helpers.assert200ForObject(response); + const objectKey = json.successful[0].key; + + response = await API.userGet( + config.userID, + `${objectPlural}/${objectKey}` + ); + + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + switch (objectType) { + case 'item': + json.data.title = 'Test 2'; + break; + + case 'collection': + case 'search': + json.data.name = 'Test 2'; + break; + } + + response = await API.userPut( + config.userID, + `${objectPlural}/${objectKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + + Helpers.assert204(response); + //check + response = await API.userGet( + config.userID, + `${objectPlural}/${objectKey}` + ); + + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + switch (objectType) { + case 'item': + assert.equal(json.data.title, 'Test 2'); + break; + + case 'collection': + case 'search': + assert.equal(json.data.name, 'Test 2'); + break; + } + }; + + it('testResponseJSONPut', async function () { + await _testResponseJSONPut('collection'); + await _testResponseJSONPut('item'); + await _testResponseJSONPut('search'); + }); + + it('testCreateByPut', async function () { + await _testCreateByPut('collection'); + await _testCreateByPut('item'); + await _testCreateByPut('search'); + }); + + const _testCreateByPut = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + const json = await API.createUnsavedDataObject(objectType); + const key = Helpers.uniqueID(); + const response = await API.userPut( + config.userID, + `${objectTypePlural}/${key}`, + JSON.stringify(json), + { + 'Content-Type': 'application/json', + 'If-Unmodified-Since-Version': '0' + } + ); + Helpers.assert204(response); + }; + + const _testEmptyVersionsResponse = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + const keyProp = objectType + 'Key'; + + const response = await API.userGet( + config.userID, + `${objectTypePlural}?format=versions&${keyProp}=NNNNNNNN`, + { 'Content-Type': 'application/json' } + ); + + Helpers.assert200(response); + + const json = JSON.parse(response.data); + + assert.isObject(json); + assert.lengthOf(Object.keys(json), 0); + }; + + const _testResponseJSONPost = async (objectType) => { + await API.userClear(config.userID); + + let objectTypePlural = await API.getPluralObjectType(objectType); + let json1, json2, conditions; + switch (objectType) { + case "collection": + json1 = { name: "Test 1" }; + json2 = { name: "Test 2" }; + break; + + case "item": + json1 = await API.getItemTemplate("book"); + json2 = { ...json1 }; + json1.title = "Test 1"; + json2.title = "Test 2"; + break; + + case "search": + conditions = [ + { condition: "title", operator: "contains", value: "value" }, + ]; + json1 = { name: "Test 1", conditions: conditions }; + json2 = { name: "Test 2", conditions: conditions }; + break; + } + + let response = await API.userPost( + config.userID, + objectTypePlural, + JSON.stringify([json1, json2]), + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + Helpers.assert200ForObject(response, false, 0); + Helpers.assert200ForObject(response, false, 1); + + response = await API.userGet(config.userID, objectTypePlural); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + switch (objectType) { + case "item": + json[0].data.title + = json[0].data.title === "Test 1" ? "Test A" : "Test B"; + json[1].data.title + = json[1].data.title === "Test 2" ? "Test B" : "Test A"; + break; + + case "collection": + case "search": + json[0].data.name + = json[0].data.name === "Test 1" ? "Test A" : "Test B"; + json[1].data.name + = json[1].data.name === "Test 2" ? "Test B" : "Test A"; + break; + } + + response = await API.userPost( + config.userID, + objectTypePlural, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + Helpers.assert200ForObject(response, false, 0); + Helpers.assert200ForObject(response, false, 1); + + // Check + response = await API.userGet(config.userID, objectTypePlural); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + switch (objectTypePlural) { + case "item": + Helpers.assertEquals("Test A", json[0].data.title); + Helpers.assertEquals("Test B", json[1].data.title); + break; + + case "collection": + case "search": + Helpers.assertEquals("Test A", json[0].data.name); + Helpers.assertEquals("Test B", json[1].data.name); + break; + } + }; + + it('test_patch_of_object_should_set_trash_state', async function () { + for (let type of types) { + let json = await API.createDataObject(type); + const data = [ + { + key: json.key, + version: json.version, + deleted: true + } + ]; + const response = await API.postObjects(type, data); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + assert.property(json.successful[0].data, 'deleted'); + // TODO: Change to true in APIv4 + if (type == 'item') { + assert.equal(json.successful[0].data.deleted, 1); + } + else { + assert.property(json.successful[0].data, 'deleted'); + } + } + }); + + it('testResponseJSONPost', async function () { + await _testResponseJSONPost('collection'); + await _testResponseJSONPost('item'); + await _testResponseJSONPost('search'); + }); + + it('testEmptyVersionsResponse', async function () { + await _testEmptyVersionsResponse('collection'); + await _testEmptyVersionsResponse('item'); + await _testEmptyVersionsResponse('search'); + }); + + it('test_patch_of_object_in_trash_without_deleted_should_not_remove_it_from_trash', async function () { + for (let i = 0; i < types.length; i++) { + const json = await API.createItem("book", { + deleted: true + }, this, 'json'); + const data = [ + { + key: json.key, + version: json.version, + title: "A" + } + ]; + const response = await API.postItems(data); + const jsonResponse = API.getJSONFromResponse(response); + + assert.property(jsonResponse.successful[0].data, 'deleted'); + assert.equal(jsonResponse.successful[0].data.deleted, 1); + } + }); +}); + diff --git a/tests/remote_js/test/3/paramsTest.js b/tests/remote_js/test/3/paramsTest.js new file mode 100644 index 00000000..e75f8c64 --- /dev/null +++ b/tests/remote_js/test/3/paramsTest.js @@ -0,0 +1,562 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('ParamsTests', function () { + this.timeout(config.timeout); + + let collectionKeys = []; + let itemKeys = []; + let searchKeys = []; + + let keysByName = { + collectionKeys: collectionKeys, + itemKeys: itemKeys, + searchKeys: searchKeys + }; + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + beforeEach(async function () { + await API.userClear(config.userID); + }); + + afterEach(async function () { + await API.userClear(config.userID); + }); + + const parseLinkHeader = (links) => { + assert.isNotNull(links); + const parsedLinks = []; + for (let link of links.split(',')) { + link = link.trim(); + let [uri, rel] = link.split('; '); + Helpers.assertRegExp(/^$/, uri); + Helpers.assertRegExp(/^rel="[a-z]+"$/, rel); + uri = uri.slice(1, -1); + rel = rel.slice(5, -1); + const params = {}; + new URLSearchParams(new URL(uri).search.slice(1)).forEach((value, key) => { + params[key] = value; + }); + parsedLinks[rel] = { + uri: uri, + params: params + }; + } + return parsedLinks; + }; + + it('testPaginationWithItemKey', async function () { + let totalResults = 27; + + for (let i = 0; i < totalResults; i++) { + await API.createItem("book", false, this, 'key'); + } + + let response = await API.userGet( + config.userID, + "items?format=keys&limit=50", + { "Content-Type": "application/json" } + ); + let keys = response.data.trim().split("\n"); + + response = await API.userGet( + config.userID, + "items?format=json&itemKey=" + keys.join(","), + { "Content-Type": "application/json" } + ); + let json = API.getJSONFromResponse(response); + Helpers.assertCount(totalResults, json); + }); + + + const _testPagination = async (objectType) => { + await API.userClear(config.userID); + const objectTypePlural = await API.getPluralObjectType(objectType); + + let limit = 2; + let totalResults = 5; + let formats = ['json', 'atom', 'keys']; + + // Create sample data + switch (objectType) { + case 'collection': + case 'item': + case 'search': + case 'tag': + await _createPaginationData(objectType, totalResults); + break; + } + let filteredFormats; + switch (objectType) { + case 'item': + formats.push('bibtex'); + break; + + case 'tag': + filteredFormats = formats.filter(val => !['keys'].includes(val)); + formats = filteredFormats; + break; + + case 'group': + // Change if the config changes + limit = 1; + totalResults = config.numOwnedGroups; + formats = formats.filter(val => !['keys'].includes(val)); + break; + } + + const func = async (start, format) => { + const response = await API.userGet( + config.userID, + `${objectTypePlural}?start=${start}&limit=${limit}&format=${format}` + ); + + Helpers.assert200(response); + Helpers.assertNumResults(response, limit); + Helpers.assertTotalResults(response, totalResults); + + const linksString = response.headers.link[0]; + const links = parseLinkHeader(linksString); + assert.property(links, 'first'); + assert.notProperty(links.first.params, 'start'); + Helpers.assertEquals(limit, links.first.params.limit); + assert.property(links, 'prev'); + + Helpers.assertEquals(limit, links.prev.params.limit); + + + assert.property(links, 'last'); + if (start < 3) { + Helpers.assertEquals(start + limit, links.next.params.start); + Helpers.assertEquals(limit, links.next.params.limit); + assert.notProperty(links.prev.params, 'start'); + assert.property(links, 'next'); + } + else { + assert.equal(Math.max(start - limit, 0), parseInt(links.prev.params.start)); + assert.notProperty(links, 'next'); + } + + let lastStart = totalResults - (totalResults % limit); + + if (lastStart == totalResults) { + lastStart -= limit; + } + + Helpers.assertEquals(lastStart, links.last.params.start); + Helpers.assertEquals(limit, links.last.params.limit); + }; + + for (const format of formats) { + const response = await API.userGet( + config.userID, + `${objectTypePlural}?limit=${limit}&format=${format}` + ); + + Helpers.assert200(response); + Helpers.assertNumResults(response, limit); + Helpers.assertTotalResults(response, totalResults); + + const linksString = response.headers.link[0]; + const links = parseLinkHeader(linksString); + assert.notProperty(links, 'first'); + assert.notProperty(links, 'prev'); + assert.property(links, 'next'); + Helpers.assertEquals(limit, links.next.params.start); + Helpers.assertEquals(limit, links.next.params.limit); + assert.property(links, 'last'); + + let lastStart = totalResults - (totalResults % limit); + + if (lastStart == totalResults) { + lastStart -= limit; + } + + Helpers.assertEquals(lastStart, links.last.params.start); + Helpers.assertEquals(limit, links.last.params.limit); + + // TODO: Test with more groups + if (objectType == 'group') { + continue; + } + + await func(1, format); + await func(2, format); + await func(3, format); + } + }; + + + it('test_should_perform_quicksearch_with_multiple_words', async function () { + let title1 = "This Is a Great Title"; + let title2 = "Great, But Is It Better Than This Title?"; + + let keys = []; + keys.push(await API.createItem("book", { + title: title1 + }, this, 'key')); + keys.push(await API.createItem("journalArticle", { + title: title2, + }, this, 'key')); + + // Search by multiple independent words + let q = "better title"; + let response = await API.userGet( + config.userID, + "items?q=" + encodeURIComponent(q) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[1], json[0].key); + + // Search by phrase + q = '"great title"'; + response = await API.userGet( + config.userID, + "items?q=" + encodeURIComponent(q) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + + // Search by non-matching phrase + q = '"better title"'; + response = await API.userGet( + config.userID, + "items?q=" + encodeURIComponent(q) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 0); + }); + + it('testFormatKeys', async function () { + for (let i = 0; i < 5; i++) { + collectionKeys.push(await API.createCollection("Test", false, null, 'key')); + itemKeys.push(await API.createItem("book", false, null, 'key')); + searchKeys.push(await API.createSearch("Test", 'default', null, 'key')); + } + itemKeys.push(await API.createAttachmentItem("imported_file", [], false, null, 'key')); + + await _testFormatKeys('collection'); + await _testFormatKeys('item'); + await _testFormatKeys('search'); + + await _testFormatKeys('collection', true); + await _testFormatKeys('item', true); + await _testFormatKeys('search', true); + }); + + const _testFormatKeys = async (objectType, sorted = false) => { + let objectTypePlural = await API.getPluralObjectType(objectType); + let keysVar = objectType + "Keys"; + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?format=keys${sorted ? "&order=title" : ""}` + ); + Helpers.assert200(response); + + let keys = response.data.trim().split("\n"); + keys.sort(); + const keysVarCopy = keysByName[keysVar]; + keysVarCopy.sort(); + assert.deepEqual(keys, keysVarCopy); + }; + + const _createPaginationData = async (objectType, num) => { + switch (objectType) { + case 'collection': + for (let i = 0; i < num; i++) { + await API.createCollection("Test", false, true, 'key'); + } + break; + + case 'item': + for (let i = 0; i < num; i++) { + await API.createItem("book", false, true, 'key'); + } + break; + + case 'search': + for (let i = 0; i < num; i++) { + await API.createSearch("Test", 'default', true, 'key'); + } + break; + + case 'tag': + await API.createItem("book", { + tags: [ + { tag: 'a' }, + { tag: 'b' } + ] + }, true); + await API.createItem("book", { + tags: [ + { tag: 'c' }, + { tag: 'd' }, + { tag: 'e' } + ] + }, true); + break; + } + }; + + it('testPagination', async function () { + await _testPagination('collection'); + await _testPagination('group'); + + await _testPagination('item'); + await _testPagination('search'); + await _testPagination('tag'); + }); + + it('testCollectionQuickSearch', async function () { + let title1 = "Test Title"; + let title2 = "Another Title"; + + let keys = []; + keys.push(await API.createCollection(title1, [], this, 'key')); + keys.push(await API.createCollection(title2, [], this, 'key')); + + // Search by title + let response = await API.userGet( + config.userID, + "collections?q=another" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[1], json[0].key); + + // No results + response = await API.userGet( + config.userID, + "collections?q=nothing" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 0); + }); + + it('test_should_include_since_parameter_in_next_link', async function () { + let totalResults = 6; + let item = await API.createItem("book", false, true, 'json'); + let since = item.version; + + for (let i = 0; i < totalResults; i++) { + await API.createItem("book", false, 'key'); + } + + let response = await API.userGet( + config.userID, + `items?limit=5&since=${since}` + ); + + let json = API.getJSONFromResponse(response); + let linkParams = parseLinkHeader(response.headers.link[0]).next.params; + + assert.equal(linkParams.limit, 5); + assert.property(linkParams, 'since'); + + assert.lengthOf(json, 5); + Helpers.assertNumResults(response, 5); + Helpers.assertTotalResults(response, totalResults); + }); + + + it('testItemQuickSearchOrderByDate', async function () { + let title1 = "Test Title"; + let title2 = "Another Title"; + let keys = []; + keys.push(await API.createItem("book", { + title: title1, + date: "February 12, 2013" + }, this, 'key')); + keys.push(await API.createItem("journalArticle", { + title: title2, + date: "November 25, 2012" + }, this, 'key')); + + // Search by title + let response = await API.userGet( + config.userID, + "items?q=" + encodeURIComponent(title1) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + + // Search by both by title, date asc + response = await API.userGet( + config.userID, + "items?q=title&sort=date&direction=asc" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[1], json[0].key); + Helpers.assertEquals(keys[0], json[1].key); + + // Search by both by title, date asc, with old-style parameters + response = await API.userGet( + config.userID, + "items?q=title&order=date&sort=asc" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[1], json[0].key); + Helpers.assertEquals(keys[0], json[1].key); + + // Search by both by title, date desc + response = await API.userGet( + config.userID, + "items?q=title&sort=date&direction=desc" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + Helpers.assertEquals(keys[1], json[1].key); + + // Search by both by title, date desc, with old-style parameters + response = await API.userGet( + config.userID, + "items?q=title&order=date&sort=desc" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + Helpers.assertEquals(keys[1], json[1].key); + }); + + it('testObjectKeyParameter', async function () { + await _testObjectKeyParameter('collection'); + await _testObjectKeyParameter('item'); + await _testObjectKeyParameter('search'); + }); + + it('testItemQuickSearch', async function () { + let title1 = "Test Title"; + let title2 = "Another Title"; + let year2 = "2013"; + + let keys = []; + keys.push(await API.createItem("book", { + title: title1 + }, this, 'key')); + keys.push(await API.createItem("journalArticle", { + title: title2, + date: "November 25, " + year2 + }, this, 'key')); + + // Search by title + let response = await API.userGet( + config.userID, + "items?q=" + encodeURIComponent(title1) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + + // TODO: Search by creator + + // Search by year + response = await API.userGet( + config.userID, + "items?q=" + year2 + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[1], json[0].key); + + // Search by year + 1 + response = await API.userGet( + config.userID, + "items?q=" + (parseInt(year2) + 1) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 0); + }); + + const _testObjectKeyParameter = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + let jsonArray = []; + switch (objectType) { + case 'collection': + jsonArray.push(await API.createCollection("Name", false, this, 'jsonData')); + jsonArray.push(await API.createCollection("Name", false, this, 'jsonData')); + break; + case 'item': + jsonArray.push(await API.createItem("book", false, this, 'jsonData')); + jsonArray.push(await API.createItem("book", false, this, 'jsonData')); + break; + case 'search': + jsonArray.push(await API.createSearch( + "Name", + [ + { + condition: "title", + operator: "contains", + value: "test", + }, + ], + this, + 'jsonData' + )); + jsonArray.push(await API.createSearch( + "Name", + [ + { + condition: "title", + operator: "contains", + value: "test", + }, + ], + this, + 'jsonData' + )); + break; + } + let keys = []; + jsonArray.forEach((json) => { + keys.push(json.key); + }); + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?${objectType}Key=${keys[0]}` + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + Helpers.assertTotalResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + + response = await API.userGet( + config.userID, + `${objectTypePlural}?${objectType}Key=${keys[0]},${keys[1]}&order=${objectType}KeyList` + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + Helpers.assertTotalResults(response, 2); + json = API.getJSONFromResponse(response); + Helpers.assertEquals(keys[0], json[0].key); + Helpers.assertEquals(keys[1], json[1].key); + }; +}); diff --git a/tests/remote_js/test/3/pdfTextExtractionTest.mjs b/tests/remote_js/test/3/pdfTextExtractionTest.mjs new file mode 100644 index 00000000..5e0c470b --- /dev/null +++ b/tests/remote_js/test/3/pdfTextExtractionTest.mjs @@ -0,0 +1,489 @@ +import chai from 'chai'; +const assert = chai.assert; +import config from 'config'; +import API from '../../api3.js'; +import Helpers from '../../helpers3.js'; +import shared from "../shared.js"; +import { S3Client, DeleteObjectsCommand } from "@aws-sdk/client-s3"; +import { SQSClient, PurgeQueueCommand } from "@aws-sdk/client-sqs"; +import fs from 'fs'; +import HTTP from '../../httpHandler.js'; +import { localInvoke } from '../../full-text-extractor/src/local_invoke.mjs'; + + +describe('PDFTextExtractionTests', function () { + this.timeout(0); + let toDelete = []; + const s3Client = new S3Client({ region: "us-east-1" }); + const sqsClient = new SQSClient(); + + before(async function () { + this.skip(); + await shared.API3Before(); + // Clean up test queue. + // Calling PurgeQueue many times in a row throws an error so sometimes we have to wait. + try { + await sqsClient.send(new PurgeQueueCommand({ QueueUrl: config.fullTextExtractorSQSUrl })); + } + catch (e) { + await new Promise(r => setTimeout(r, 5000)); + await sqsClient.send(new PurgeQueueCommand({ QueueUrl: config.fullTextExtractorSQSUrl })); + } + + try { + fs.mkdirSync("./work"); + } + catch {} + }); + + after(async function () { + await shared.API3After(); + fs.rm("./work", { recursive: true, force: true }, (e) => { + if (e) console.log(e); + }); + if (toDelete.length > 0) { + const commandInput = { + Bucket: config.s3Bucket, + Delete: { + Objects: toDelete.map((x) => { + return { Key: x }; + }) + } + }; + const command = new DeleteObjectsCommand(commandInput); + await s3Client.send(command); + } + }); + + beforeEach(async () => { + API.useAPIKey(config.apiKey); + }); + + it('should_extract_pdf_text', async function () { + let json = await API.createItem("book", false, this, 'json'); + assert.equal(0, json.meta.numChildren); + let parentKey = json.key; + + json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'json'); + let attachmentKey = json.key; + let version = json.version; + + let filename = "dummy.pdf"; + let mtime = Date.now(); + const pdfText = makeRandomPDF(); + + let fileContents = fs.readFileSync("./work/dummy.pdf"); + let size = Buffer.from(fileContents.toString()).byteLength; + let md5 = Helpers.md5(fileContents.toString()); + + // Create attachment item + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([ + { + key: attachmentKey, + contentType: "application/pdf", + } + ]), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assert200ForObject(response); + + // Get upload authorization + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + Helpers.implodeParams({ + md5: md5, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + // Upload + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": json.contentType + } + ); + Helpers.assert201(response); + + // Post-upload file registration + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + toDelete.push(md5); + + // Local invoke full-text-extractor if it's a local test run + if (config.isLocalRun) { + await new Promise(r => setTimeout(r, 5000)); + const processedCount = await localInvoke(); + assert.equal(processedCount, 1); + } + else { + // If it's a run on AWS, just wait for lambda to finish + await new Promise(r => setTimeout(r, 10000)); + } + + // Get full text to ensure full-text-extractor worked + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext", + ); + Helpers.assert200(response); + const data = JSON.parse(response.data); + assert.property(data, 'content'); + assert.equal(data.content.trim(), pdfText); + }); + + it('should_not_add_non_pdf_to_queue', async function () { + let json = await API.createItem("book", false, this, 'json'); + assert.equal(0, json.meta.numChildren); + let parentKey = json.key; + + json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'json'); + let attachmentKey = json.key; + let version = json.version; + + let filename = "dummy.txt"; + let mtime = Date.now(); + + let fileContents = Helpers.getRandomUnicodeString(); + let size = Buffer.from(fileContents).byteLength; + let md5 = Helpers.md5(fileContents); + + // Create attachment item + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([ + { + key: attachmentKey, + contentType: "text/plain", + } + ]), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assert200ForObject(response); + + // Get upload authorization + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + Helpers.implodeParams({ + md5: md5, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + // Upload + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": json.contentType + } + ); + Helpers.assert201(response); + + // Post-upload file registration + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert204(response); + + toDelete.push(md5); + + // Local invoke full-text-extractor if it's a local test run + if (config.isLocalRun) { + // Wait for SQS to make the message available + await new Promise(r => setTimeout(r, 5000)); + const processedCount = await localInvoke(); + assert.equal(processedCount, 0); + } + else { + // If it's a run on AWS, just wait for lambda to finish + await new Promise(r => setTimeout(r, 10000)); + } + + + // Get full text to ensure full-text-extractor was not triggered + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext", + ); + Helpers.assert404(response); + }); + + it('should_not_add_pdf_from_desktop_client_to_queue', async function () { + let json = await API.createItem("book", false, this, 'json'); + assert.equal(0, json.meta.numChildren); + let parentKey = json.key; + + json = await API.createAttachmentItem("imported_file", [], parentKey, this, 'json'); + let attachmentKey = json.key; + let version = json.version; + + let filename = "dummy.pdf"; + let mtime = Date.now(); + makeRandomPDF(); + + let fileContents = fs.readFileSync("./work/dummy.pdf"); + let size = Buffer.from(fileContents.toString()).byteLength; + let md5 = Helpers.md5(fileContents.toString()); + + // Create attachment item + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([ + { + key: attachmentKey, + contentType: "application/pdf", + } + ]), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + } + ); + Helpers.assert200ForObject(response); + + // Get upload authorization + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + Helpers.implodeParams({ + md5: md5, + mtime: mtime, + filename: filename, + filesize: size + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*" + } + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + + // Upload + response = await HTTP.post( + json.url, + json.prefix + fileContents + json.suffix, + { + "Content-Type": json.contentType + } + ); + Helpers.assert201(response); + + // Post-upload file registration + response = await API.userPost( + config.userID, + "items/" + attachmentKey + "/file", + "upload=" + json.uploadKey, + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*", + "X-Zotero-Version": "6.0.0" + } + ); + Helpers.assert204(response); + + toDelete.push(md5); + + // Local invoke full-text-extractor if it's a local test run + if (config.isLocalRun) { + await new Promise(r => setTimeout(r, 5000)); + const processedCount = await localInvoke(); + assert.equal(processedCount, 0); + } + else { + // If it's a run on AWS, just wait for lambda to finish + await new Promise(r => setTimeout(r, 10000)); + } + + // Get full text to ensure full-text-extractor was not called + response = await API.userGet( + config.userID, + "items/" + attachmentKey + "/fulltext", + ); + Helpers.assert404(response); + }); + + it('should_extract_pdf_text_group', async function () { + let filename = "dummy.pdf"; + let mtime = Date.now(); + const pdfText = makeRandomPDF(); + + let fileContents = fs.readFileSync("./work/dummy.pdf"); + let size = Buffer.from(fileContents.toString()).byteLength; + let hash = Helpers.md5(fileContents.toString()); + + let groupID = await API.createGroup({ + owner: config.userID, + type: "PublicClosed", + name: Helpers.uniqueID(14), + libraryReading: "all", + fileEditing: "members", + }); + + let parentKey = await API.groupCreateItem(groupID, "book", false, this, "key"); + let attachmentKey = await API.groupCreateAttachmentItem( + groupID, + "imported_file", + { + contentType: "text/plain", + charset: "utf-8", + }, + parentKey, + this, + "key" + ); + + // Get authorization + let response = await API.groupPost( + groupID, + `items/${attachmentKey}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size, + }), + { + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*", + }, + + ); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + + toDelete.push(hash); + + // + // Upload to S3 + // + response = await HTTP.post(json.url, `${json.prefix}${fileContents}${json.suffix}`, { + + "Content-Type": `${json.contentType}`, + }, + ); + Helpers.assert201(response); + + // Successful registration + response = await API.groupPost( + groupID, + `items/${attachmentKey}/file`, + `upload=${json.uploadKey}`, + { + + "Content-Type": "application/x-www-form-urlencoded", + "If-None-Match": "*", + }, + + ); + Helpers.assert204(response); + toDelete.push(hash); + + // Local invoke full-text-extractor if it's a local test run + if (config.isLocalRun) { + await new Promise(r => setTimeout(r, 5000)); + const processedCount = await localInvoke(); + assert.equal(processedCount, 1); + } + else { + // If it's a run on AWS, just wait for lambda to finish + await new Promise(r => setTimeout(r, 10000)); + } + + // Get full text to ensure full-text-extractor worked + response = await API.groupGet( + groupID, + "items/" + attachmentKey + "/fulltext", + ); + Helpers.assert200(response); + const data = JSON.parse(response.data); + assert.property(data, 'content'); + assert.equal(data.content.trim(), pdfText); + await API.deleteGroup(groupID); + }); + + + const makeRandomPDF = () => { + const randomText = Helpers.uniqueToken(); + const pdfData = `%PDF-1.4 +1 0 obj <> +endobj +2 0 obj <> +endobj +3 0 obj<> +endobj +4 0 obj<>>> +endobj +5 0 obj<> +endobj +6 0 obj +<> +stream +BT /F1 24 Tf 175 720 Td (${randomText})Tj ET +endstream +endobj +xref +0 7 +0000000000 65535 f +0000000009 00000 n +0000000056 00000 n +0000000111 00000 n +0000000212 00000 n +0000000250 00000 n +0000000317 00000 n +trailer <> +startxref +406 +%%EOF`; + fs.writeFileSync(`./work/dummy.pdf`, pdfData); + return randomText; + }; +}); + + diff --git a/tests/remote_js/test/3/permissionTest.js b/tests/remote_js/test/3/permissionTest.js new file mode 100644 index 00000000..91759d1e --- /dev/null +++ b/tests/remote_js/test/3/permissionTest.js @@ -0,0 +1,376 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After, resetGroups } = require("../shared.js"); + +describe('PermissionsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + beforeEach(async function () { + await resetGroups(); + await API.resetKey(config.apiKey); + API.useAPIKey(config.apiKey); + await API.setKeyUserPermission(config.apiKey, 'library', true); + await API.setKeyUserPermission(config.apiKey, 'write', true); + await API.setKeyGroupPermission(config.apiKey, 0, 'write', true); + }); + + it('testUserGroupsAnonymousJSON', async function () { + API.useAPIKey(false); + const response = await API.get(`users/${config.userID}/groups`); + Helpers.assertStatusCode(response, 200); + + // Make sure they're the right groups + const json = API.getJSONFromResponse(response); + const groupIDs = json.map(obj => String(obj.id)); + assert.include(groupIDs, String(config.ownedPublicGroupID), `Owned public group ID ${config.ownedPublicGroupID} not found`); + assert.include(groupIDs, String(config.ownedPublicNoAnonymousGroupID), `Owned public no-anonymous group ID ${config.ownedPublicNoAnonymousGroupID} not found`); + Helpers.assertTotalResults(response, config.numPublicGroups); + }); + + it('testUserGroupsAnonymousAtom', async function () { + API.useAPIKey(false); + const response = await API.get(`users/${config.userID}/groups?content=json`); + Helpers.assertStatusCode(response, 200); + + // Make sure they're the right groups + const xml = API.getXMLFromResponse(response); + const groupIDs = Helpers.xpathEval(xml, '//atom:entry/zapi:groupID', false, true); + assert.include(groupIDs, String(config.ownedPublicGroupID), `Owned public group ID ${config.ownedPublicGroupID} not found`); + assert.include(groupIDs, String(config.ownedPublicNoAnonymousGroupID), `Owned public no-anonymous group ID ${config.ownedPublicNoAnonymousGroupID} not found`); + Helpers.assertTotalResults(response, config.numPublicGroups); + }); + + /** + * A key without note access shouldn't be able to create a note + */ + it('testKeyNoteAccessWriteError', async function () { + this.skip(); //disabled + }); + + it('testUserGroupsOwned', async function () { + API.useAPIKey(config.apiKey); + const response = await API.get( + "users/" + config.userID + "/groups" + ); + Helpers.assertStatusCode(response, 200); + + Helpers.assertTotalResults(response, config.numOwnedGroups); + Helpers.assertNumResults(response, config.numOwnedGroups); + }); + + it('testTagDeletePermissions', async function () { + await API.userClear(config.userID); + + await API.createItem('book', { + tags: [{ tag: 'A' }] + }, true); + + const libraryVersion = await API.getLibraryVersion(); + + await API.setKeyUserPermission( + config.apiKey, 'write', false + ); + + let response = await API.userDelete( + config.userID, + `tags?tag=A&key=${config.apiKey}`, + ); + Helpers.assertStatusCode(response, 403); + + await API.setKeyUserPermission( + config.apiKey, 'write', true + ); + + response = await API.userDelete( + config.userID, + `tags?tag=A&key=${config.apiKey}`, + { 'If-Unmodified-Since-Version': libraryVersion } + ); + Helpers.assertStatusCode(response, 204); + }); + + it('test_should_see_private_group_listed_when_using_key_with_library_read_access', async function () { + await API.resetKey(config.apiKey); + let response = await API.userGet(config.userID, "groups"); + Helpers.assert200(response); + Helpers.assertNumResults(response, config.numPublicGroups); + + // Grant key read permission to library + await API.setKeyGroupPermission( + config.apiKey, + config.ownedPrivateGroupID, + 'library', + true + ); + + response = await API.userGet(config.userID, "groups"); + Helpers.assertNumResults(response, config.numOwnedGroups); + Helpers.assertTotalResults(response, config.numOwnedGroups); + + const json = API.getJSONFromResponse(response); + const groupIDs = json.map(data => data.id); + assert.include(groupIDs, config.ownedPrivateGroupID); + }); + + + it('testGroupLibraryReading', async function () { + const groupID = config.ownedPublicNoAnonymousGroupID; + await API.groupClear(groupID); + + await API.groupCreateItem( + groupID, + 'book', + { + title: "Test" + }, + true + ); + + try { + API.useAPIKey(config.apiKey); + let response = await API.groupGet(groupID, "items"); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + + // An anonymous request should fail, because libraryReading is members + API.useAPIKey(false); + response = await API.groupGet(groupID, "items"); + Helpers.assert403(response); + } + finally { + await API.groupClear(groupID); + } + }); + + + it('test_shouldnt_be_able_to_write_to_group_using_key_with_library_read_access', async function () { + await API.resetKey(config.apiKey); + + // Grant key read (not write) permission to library + await API.setKeyGroupPermission( + config.apiKey, + config.ownedPrivateGroupID, + 'library', + true + ); + + let response = await API.get("items/new?itemType=book"); + let json = JSON.parse(response.data); + + response = await API.groupPost( + config.ownedPrivateGroupID, + "items", + JSON.stringify({ + items: [json] + }), + { "Content-Type": "application/json" } + ); + Helpers.assert403(response); + }); + + + it('testKeyNoteAccess', async function () { + await API.userClear(config.userID); + + await API.setKeyUserPermission(config.apiKey, 'notes', true); + + let keys = []; + let topLevelKeys = []; + let bookKeys = []; + + const makeNoteItem = async (text) => { + const key = await API.createNoteItem(text, false, true, 'key'); + keys.push(key); + topLevelKeys.push(key); + }; + + const makeBookItem = async (title) => { + let key = await API.createItem('book', { title: title }, true, 'key'); + keys.push(key); + topLevelKeys.push(key); + bookKeys.push(key); + return key; + }; + + await makeBookItem("A"); + + await makeNoteItem("B"); + await makeNoteItem("C"); + await makeNoteItem("D"); + await makeNoteItem("E"); + + const lastKey = await makeBookItem("F"); + + let key = await API.createNoteItem("G", lastKey, true, 'key'); + keys.push(key); + + // Create collection and add items to it + let response = await API.userPost( + config.userID, + "collections", + JSON.stringify([ + { + name: "Test", + parentCollection: false + } + ]), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + let collectionKey = API.getFirstSuccessKeyFromResponse(response); + + response = await API.userPost( + config.userID, + `collections/${collectionKey}/items`, + topLevelKeys.join(" ") + ); + Helpers.assertStatusCode(response, 204); + + // + // format=atom + // + // Root + response = await API.userGet( + config.userID, "items" + ); + Helpers.assertNumResults(response, keys.length); + Helpers.assertTotalResults(response, keys.length); + + // Top + response = await API.userGet( + config.userID, "items/top" + ); + Helpers.assertNumResults(response, topLevelKeys.length); + Helpers.assertTotalResults(response, topLevelKeys.length); + + // Collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items/top" + ); + Helpers.assertNumResults(response, topLevelKeys.length); + Helpers.assertTotalResults(response, topLevelKeys.length); + + // + // format=keys + // + // Root + response = await API.userGet( + config.userID, + "items?format=keys" + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.data.trim().split("\n").length, keys.length); + + // Top + response = await API.userGet( + config.userID, + "items/top?format=keys" + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.data.trim().split("\n").length, topLevelKeys.length); + + // Collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items/top?format=keys" + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.data.trim().split("\n").length, topLevelKeys.length); + + // Remove notes privilege from key + await API.setKeyUserPermission(config.apiKey, "notes", false); + // + // format=json + // + // totalResults with limit + response = await API.userGet( + config.userID, + "items?limit=1" + ); + Helpers.assertNumResults(response, 1); + Helpers.assertTotalResults(response, bookKeys.length); + + // And without limit + response = await API.userGet( + config.userID, + "items" + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // Top + response = await API.userGet( + config.userID, + "items/top" + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // Collection + response = await API.userGet( + config.userID, + `collections/${collectionKey}/items` + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // + // format=atom + // + // totalResults with limit + response = await API.userGet( + config.userID, + "items?format=atom&limit=1" + ); + Helpers.assertNumResults(response, 1); + Helpers.assertTotalResults(response, bookKeys.length); + + // And without limit + response = await API.userGet( + config.userID, + "items?format=atom" + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // Top + response = await API.userGet( + config.userID, + "items/top?format=atom" + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // Collection + response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items?format=atom" + ); + Helpers.assertNumResults(response, bookKeys.length); + Helpers.assertTotalResults(response, bookKeys.length); + + // + // format=keys + // + response = await API.userGet( + config.userID, + "items?format=keys" + ); + keys = response.data.trim().split("\n"); + keys.sort(); + bookKeys.sort(); + assert.deepEqual(bookKeys, keys); + }); +}); diff --git a/tests/remote_js/test/3/publicationTest.js b/tests/remote_js/test/3/publicationTest.js new file mode 100644 index 00000000..3eb20f42 --- /dev/null +++ b/tests/remote_js/test/3/publicationTest.js @@ -0,0 +1,779 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After, resetGroups } = require("../shared.js"); +const { S3Client, DeleteObjectsCommand } = require("@aws-sdk/client-s3"); +const HTTP = require('../../httpHandler.js'); +const fs = require('fs'); +const { JSDOM } = require("jsdom"); + + +describe('PublicationTests', function () { + this.timeout(0); + let toDelete = []; + const s3Client = new S3Client({ region: "us-east-1" }); + + before(async function () { + await API3Before(); + await resetGroups(); + try { + fs.mkdirSync("./work"); + } + catch {} + }); + + after(async function () { + await API3After(); + fs.rm("./work", { recursive: true, force: true }, (e) => { + if (e) console.log(e); + }); + if (toDelete.length > 0) { + const commandInput = { + Bucket: config.s3Bucket, + Delete: { + Objects: toDelete.map((x) => { + return { Key: x }; + }) + } + }; + const command = new DeleteObjectsCommand(commandInput); + await s3Client.send(command); + } + }); + beforeEach(async function () { + await API.userClear(config.userID); + API.useAPIKey(""); + }); + + + it('test_should_return_404_for_collections_request', async function () { + let response = await API.get(`users/${config.userID}/publications/collections`, { "Content-Type": "application/json" }); + Helpers.assert404(response); + }); + + it('test_should_show_publications_urls_in_json_response_for_multi_object_request', async function () { + API.useAPIKey(config.apiKey); + const itemKey1 = await API.createItem("book", { inPublications: true }, this, 'key'); + const itemKey2 = await API.createItem("book", { inPublications: true }, this, 'key'); + + const response = await API.get("users/" + config.userID + "/publications/items?limit=1", { "Content-Type": "application/json" }); + const json = API.getJSONFromResponse(response); + + const links = await API.parseLinkHeader(response); + + // Entry rel="self" + Helpers.assertRegExp( + `https?://[^/]+/users/${config.userID}/publications/items/(${itemKey1}|${itemKey2})`, + json[0].links.self.href + ); + + // rel="next" + Helpers.assertRegExp( + `https?://[^/]+/users/${config.userID}/publications/items`, + links.next + ); + + // TODO: rel="alternate" (what should this be?) + }); + + it('test_should_trigger_notification_on_publications_topic', async function () { + API.useAPIKey(config.apiKey); + // Create item + const response = await API.createItem('book', { inPublications: true }, this, 'response'); + const version = API.getJSONFromResponse(response).successful[0].version; + // Test notification for publications topic (in addition to regular library) + Helpers.assertNotificationCount(2, response); + Helpers.assertHasNotification({ + event: 'topicUpdated', + topic: `/users/${config.userID}`, + version: version + }, response); + Helpers.assertHasNotification({ + event: 'topicUpdated', + topic: `/users/${config.userID}/publications` + }, response); + }); + + it('test_should_show_publications_urls_in_atom_response_for_single_object_request', async function () { + API.useAPIKey(config.apiKey); + const itemKey = await API.createItem('book', { inPublications: true }, this, 'key'); + const response = await API.get(`users/${config.userID}/publications/items/${itemKey}?format=atom`); + const xml = API.getXMLFromResponse(response); + + // id + Helpers.assertRegExp( + `http://[^/]+/users/${config.userID}/items/${itemKey}`, + Helpers.xpathEval(xml, '//atom:id') + ); + + // rel="self" + const selfRel = Helpers.xpathEval(xml, '//atom:link[@rel="self"]', true, false); + Helpers.assertRegExp( + `https?://[^/]+/users/${config.userID}/publications/items/${itemKey}\\?format=atom`, + selfRel.getAttribute("href") + ); + + // TODO: rel="alternate" + }); + + // Disabled + it('test_should_return_304_for_request_with_etag', async function () { + this.skip(); + let response = await API.get(`users/${config.userID}/publications/items`); + Helpers.assert200(response); + let etag = response.headers.etag[0]; + Helpers.assertNotNull(etag); + + response = await API.get( + `users/${config.userID}/publications/items`, + { + "If-None-Match": etag + } + ); + Helpers.assert304(response); + assert.equal(etag, response.headers.etag[0]); + }); + + it('test_should_show_publications_urls_in_json_response_for_single_object_request', async function () { + API.useAPIKey(config.apiKey); + const itemKey = await API.createItem("book", { inPublications: true }, this, 'key'); + + const response = await API.get(`users/${config.userID}/publications/items/${itemKey}`); + const json = API.getJSONFromResponse(response); + + // rel="self" + Helpers.assertRegExp( + `https?://[^/]+/users/${config.userID}/publications/items/${itemKey}`, + json.links.self.href + ); + }); + + it('test_should_return_no_atom_results_for_empty_publications_list', async function () { + let response = await API.get(`users/${config.userID}/publications/items?format=atom`); + Helpers.assert200(response); + Helpers.assertNoResults(response); + assert.isNumber(parseInt(response.headers['last-modified-version'][0])); + }); + + it('test_shouldnt_include_hidden_child_items_in_numChildren', async function () { + API.useAPIKey(config.apiKey); + // Create parent item + const parentItemKey = await API.createItem('book', { inPublications: true }, this, 'key'); + + // Create shown child attachment + const json1 = await API.getItemTemplate('attachment&linkMode=imported_file'); + json1.title = 'A'; + json1.parentItem = parentItemKey; + json1.inPublications = true; + + // Create shown child note + const json2 = await API.getItemTemplate('note'); + json2.note = 'B'; + json2.parentItem = parentItemKey; + json2.inPublications = true; + + // Create hidden child attachment + const json3 = await API.getItemTemplate('attachment&linkMode=imported_file'); + json3.title = 'C'; + json3.parentItem = parentItemKey; + + // Create deleted child attachment + const json4 = await API.getItemTemplate('note'); + json4.note = 'D'; + json4.parentItem = parentItemKey; + json4.inPublications = true; + json4.deleted = true; + + // Create hidden deleted child attachment + const json5 = await API.getItemTemplate('attachment&linkMode=imported_file'); + json5.title = 'E'; + json5.parentItem = parentItemKey; + json5.deleted = true; + + let response = await API.userPost(config.userID, 'items', JSON.stringify([json1, json2, json3, json4, json5])); + Helpers.assert200(response); + + // Anonymous read + API.useAPIKey(''); + + response = await API.userGet(config.userID, `publications/items/${parentItemKey}`); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + assert.equal(2, json.meta.numChildren); + + response = await API.userGet(config.userID, `publications/items/${parentItemKey}/children`); + + response = await API.userGet(config.userID, `publications/items/${parentItemKey}?format=atom`); + Helpers.assert200(response); + const xml = API.getXMLFromResponse(response); + assert.equal(2, parseInt(Helpers.xpathEval(xml, '/atom:entry/zapi:numChildren'))); + }); + + it('testLinkedFileAttachment', async function () { + let json = await API.getItemTemplate("book"); + json.inPublications = true; + API.useAPIKey(config.apiKey); + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]) + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + let itemKey = json.successful[0].key; + + json = await API.getItemTemplate("attachment&linkMode=linked_file"); + json.inPublications = true; + json.parentItem = itemKey; + API.useAPIKey(config.apiKey); + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, { message: "Linked-file attachments cannot be added to My Publications" }); + }); + + it('testTopLevelAttachmentAndNote', async function () { + let msg = "Top-level notes and attachments cannot be added to My Publications"; + + // Attachment + API.useAPIKey(config.apiKey); + let json = await API.getItemTemplate("attachment&linkMode=imported_file"); + json.inPublications = true; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, msg, 0); + + // Note + API.useAPIKey(config.apiKey); + json = await API.getItemTemplate("note"); + json.inPublications = true; + response = await API.userPost( + config.userID, + "items", + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert400ForObject(response, msg, 0); + }); + + it('test_shouldnt_allow_inPublications_in_group_library', async function () { + API.useAPIKey(config.apiKey); + let json = await API.getItemTemplate("book"); + json.inPublications = true; + const response = await API.groupPost(config.ownedPrivateGroupID, "items", JSON.stringify([json]), { "Content-Type": "application/json" }); + Helpers.assert400ForObject(response, { message: "Group items cannot be added to My Publications" }); + }); + + it('test_should_show_item_for_anonymous_single_object_request', async function () { + // Create item + API.useAPIKey(config.apiKey); + const itemKey = await API.createItem('book', { inPublications: true }, this, 'key'); + + // Read item anonymously + API.useAPIKey(''); + + // JSON + let response = await API.userGet(config.userID, `publications/items/${itemKey}`); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + assert.equal(config.displayName, json.library.name); + assert.equal('user', json.library.type); + + // Atom + response = await API.userGet(config.userID, `publications/items/${itemKey}?format=atom`); + Helpers.assert200(response); + const xml = API.getXMLFromResponse(response); + const author = xml.getElementsByTagName("author")[0]; + const name = author.getElementsByTagName("name")[0]; + assert.equal(config.displayName, name.innerHTML); + }); + + it('test_should_remove_inPublications_on_POST_with_false', async function () { + API.useAPIKey(config.apiKey); + let json = await API.getItemTemplate('book'); + json.inPublications = true; + let response = await API.userPost(config.userID, 'items', JSON.stringify([json])); + Helpers.assert200(response); + let key = API.getJSONFromResponse(response).successful[0].key; + let version = response.headers['last-modified-version'][0]; + json = { + key, + version, + title: 'Test', + inPublications: false, + }; + response = await API.userPost(config.userID, 'items', JSON.stringify([json]), { + 'Content-Type': 'application/json', + }); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + assert.notProperty(json.successful[0].data, 'inPublications'); + }); + + it('test_should_return_404_for_anonymous_request_for_item_not_in_publications', async function () { + API.useAPIKey(config.apiKey); + // Create item + const key = await API.createItem("book", [], this, 'key'); + + // Fetch anonymously + API.useAPIKey(''); + const response = await API.get("users/" + config.userID + "/publications/items/" + key, { "Content-Type": "application/json" }); + Helpers.assert404(response); + }); + + // Test read requests for empty publications list + it('test_should_return_no_results_for_empty_publications_list_with_key', async function () { + API.useAPIKey(config.apiKey); + let response = await API.get(`users/${config.userID}/publications/items`); + Helpers.assert200(response); + Helpers.assertNoResults(response); + assert.isNumber(parseInt(response.headers['last-modified-version'][0])); + }); + + it('test_should_show_item_for_anonymous_multi_object_request', async function () { + // Create item + API.useAPIKey(config.apiKey); + let itemKey = await API.createItem('book', { inPublications: true }, this, 'key'); + + // Read item anonymously + API.useAPIKey(''); + + // JSON + let response = await API.userGet(config.userID, 'publications/items'); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + assert.include(json.map(item => item.key), itemKey); + + // Atom + response = await API.userGet(config.userID, 'publications/items?format=atom'); + Helpers.assert200(response); + let xml = API.getXMLFromResponse(response); + let xpath = Helpers.xpathEval(xml, '//atom:entry/zapi:key'); + assert.include(xpath, itemKey); + }); + + it('test_should_show_publications_urls_in_atom_response_for_multi_object_request', async function () { + let response = await API.get(`users/${config.userID}/publications/items?format=atom`); + let xml = API.getXMLFromResponse(response); + + // id + let id = Helpers.xpathEval(xml, '//atom:id'); + Helpers.assertRegExp(`http://[^/]+/users/${config.userID}/publications/items`, id); + + let link = Helpers.xpathEval(xml, '//atom:link[@rel="self"]', true, false); + let href = link.getAttribute('href'); + Helpers.assertRegExp(`https?://[^/]+/users/${config.userID}/publications/items\\?format=atom`, href); + + // rel="first" + link = Helpers.xpathEval(xml, '//atom:link[@rel="first"]', true, false); + href = link.getAttribute('href'); + Helpers.assertRegExp(`https?://[^/]+/users/${config.userID}/publications/items\\?format=atom`, href); + + // TODO: rel="alternate" (what should this be?) + }); + + it('test_should_return_200_for_deleted_request', async function () { + let response = await API.get(`users/${config.userID}/publications/deleted?since=0`, { 'Content-Type': 'application/json' }); + Helpers.assert200(response); + }); + + // Disabled until after integrated My Publications upgrade + it('test_should_return_404_for_settings_request', async function () { + this.skip(); + let response = await API.get(`users/${config.userID}/publications/settings`); + Helpers.assert404(response); + }); + + it('test_should_return_404_for_authenticated_request_for_item_not_in_publications', async function () { + API.useAPIKey(config.apiKey); + // Create item + let key = await API.createItem("book", [], this, 'key'); + + // Fetch anonymously + let response = await API.get("users/" + config.userID + "/publications/items/" + key, { "Content-Type": "application/json" }); + Helpers.assert404(response); + }); + + it('test_shouldnt_show_trashed_item', async function () { + API.useAPIKey(config.apiKey); + const itemKey = await API.createItem("book", { inPublications: true, deleted: true }, this, 'key'); + + const response = await API.userGet( + config.userID, + "publications/items/" + itemKey + ); + Helpers.assert404(response); + }); + + it('test_should_return_400_for_settings_request_with_items', async function () { + API.useAPIKey(config.apiKey); + let response = await API.createItem("book", { inPublications: true }, this, 'response'); + Helpers.assert200ForObject(response); + + response = await API.get(`users/${config.userID}/publications/settings`); + assert.equal(response.status, 400); + }); + + // Disabled until after integrated My Publications upgrade + it('test_should_return_404_for_deleted_request', async function () { + this.skip(); + let response = await API.get(`users/${config.userID}/publications/deleted?since=0`); + Helpers.assert404(response); + }); + + it('test_should_return_no_results_for_empty_publications_list', async function () { + let response = await API.get(`users/${config.userID}/publications/items`); + Helpers.assert200(response); + Helpers.assertNoResults(response); + assert.isNumber(parseInt(response.headers['last-modified-version'][0])); + }); + + it('test_shouldnt_show_restricted_properties', async function () { + API.useAPIKey(config.apiKey); + let itemKey = await API.createItem('book', { inPublications: true }, this, 'key'); + + // JSON + let response = await API.userGet(config.userID, `publications/items/${itemKey}`); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + assert.notProperty(json.data, 'inPublications'); + assert.notProperty(json.data, 'collections'); + assert.notProperty(json.data, 'relations'); + assert.notProperty(json.data, 'tags'); + assert.notProperty(json.data, 'dateAdded'); + assert.notProperty(json.data, 'dateModified'); + + // Atom + response = await API.userGet(config.userID, `publications/items/${itemKey}?format=atom&content=html,json`); + Helpers.assert200(response); + + // HTML in Atom + let html = await API.getContentFromAtomResponse(response, 'html'); + let doc = (new JSDOM(html.innerHTML)).window.document; + let trs = Array.from(doc.getElementsByTagName("tr")); + let publications = trs.filter(node => node.getAttribute("class") == "publication"); + assert.equal(publications.length, 0); + + // JSON in Atom + let atomJson = await API.getContentFromAtomResponse(response, 'json'); + assert.notProperty(atomJson, 'inPublications'); + assert.notProperty(atomJson, 'collections'); + assert.notProperty(atomJson, 'relations'); + assert.notProperty(atomJson, 'tags'); + assert.notProperty(atomJson, 'dateAdded'); + assert.notProperty(atomJson, 'dateModified'); + }); + + it('test_shouldnt_remove_inPublications_on_POST_without_property', async function () { + API.useAPIKey(config.apiKey); + let json = await API.getItemTemplate('book'); + json.inPublications = true; + const response = await API.userPost(config.userID, 'items', JSON.stringify([json])); + + Helpers.assert200(response); + const key = API.getJSONFromResponse(response).successful[0].key; + const version = response.headers['last-modified-version'][0]; + + json = { + key: key, + version: version, + title: 'Test' + }; + + const newResponse = await API.userPost( + config.userID, + 'items', + JSON.stringify([json]), + { 'Content-Type': 'application/json' } + ); + + Helpers.assert200ForObject(newResponse); + + const newJsonResponse = API.getJSONFromResponse(newResponse); + + assert.ok(newJsonResponse.successful[0].data.inPublications); + }); + + it('test_should_return_404_for_searches_request', async function () { + let response = await API.get(`users/${config.userID}/publications/searches`); + Helpers.assert404(response); + }); + + it('test_shouldnt_show_child_items_in_top_mode', async function () { + API.useAPIKey(config.apiKey); + + // Create parent item + let parentItemKey = await API.createItem("book", { title: 'A', inPublications: true }, this, 'key'); + + // Create shown child attachment + let json1 = await API.getItemTemplate("attachment&linkMode=imported_file"); + json1.title = 'B'; + json1.parentItem = parentItemKey; + json1.inPublications = true; + + // Create hidden child attachment + let json2 = await API.getItemTemplate("attachment&linkMode=imported_file"); + json2.title = 'C'; + json2.parentItem = parentItemKey; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([json1, json2]) + ); + + Helpers.assert200(response); + // Anonymous read + API.useAPIKey(""); + + response = await API.userGet( + config.userID, + "publications/items/top" + ); + + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + + assert.equal(json.length, 1); + + let titles = json.map(item => item.data.title); + + assert.include(titles, 'A'); + }); + + it('test_shouldnt_show_child_item_not_in_publications_for_item_children_request', async function () { + API.useAPIKey(config.apiKey); + // Create parent item + const parentItemKey = await API.createItem("book", { title: 'A', inPublications: true }, this, 'key'); + + // Create shown child attachment + const json1 = await API.getItemTemplate("attachment&linkMode=imported_file"); + json1.title = 'B'; + json1.parentItem = parentItemKey; + json1.inPublications = true; + // Create hidden child attachment + const json2 = await API.getItemTemplate("attachment&linkMode=imported_file"); + json2.title = 'C'; + json2.parentItem = parentItemKey; + const response = await API.userPost(config.userID, "items", JSON.stringify([json1, json2])); + Helpers.assert200(response); + + // Anonymous read + API.useAPIKey(""); + + const response2 = await API.userGet(config.userID, `publications/items/${parentItemKey}/children`); + Helpers.assert200(response2); + const json = API.getJSONFromResponse(response2); + assert.equal(json.length, 1); + const titles = json.map(item => item.data.title); + assert.include(titles, 'B'); + }); + + it('test_shouldnt_show_child_item_not_in_publications', async function () { + API.useAPIKey(config.apiKey); + // Create parent item + const parentItemKey = await API.createItem('book', { title: 'A', inPublications: true }, this, 'key'); + + // Create shown child attachment + const json1 = await API.getItemTemplate('attachment&linkMode=imported_file'); + json1.title = 'B'; + json1.parentItem = parentItemKey; + json1.inPublications = true; + // Create hidden child attachment + const json2 = await API.getItemTemplate('attachment&linkMode=imported_file'); + json2.title = 'C'; + json2.parentItem = parentItemKey; + const response = await API.userPost(config.userID, 'items', JSON.stringify([json1, json2])); + Helpers.assert200(response); + // Anonymous read + API.useAPIKey(''); + const readResponse = await API.userGet(config.userID, 'publications/items'); + Helpers.assert200(readResponse); + const json = API.getJSONFromResponse(readResponse); + Helpers.assertCount(2, json); + const titles = json.map(item => item.data.title); + assert.include(titles, 'A'); + assert.include(titles, 'B'); + assert.notInclude(titles, 'C'); + }); + + it('test_should_return_200_for_settings_request_with_no_items', async function () { + let response = await API.get(`users/${config.userID}/publications/settings`); + Helpers.assert200(response); + Helpers.assertNoResults(response); + }); + + it('test_should_return_403_for_anonymous_write', async function () { + const json = await API.getItemTemplate("book"); + const response = await API.userPost(config.userID, "publications/items", JSON.stringify(json)); + Helpers.assert403(response); + }); + + it('test_should_return_405_for_authenticated_write', async function () { + API.useAPIKey(config.apiKey); + const json = await API.getItemTemplate('book'); + const response = await API.userPost(config.userID, 'publications/items', JSON.stringify(json), { 'Content-Type': 'application/json' }); + Helpers.assert405(response); + }); + + it('test_shouldnt_show_trashed_item_in_versions_response', async function () { + API.useAPIKey(config.apiKey); + let itemKey1 = await API.createItem("book", { inPublications: true }, this, 'key'); + let itemKey2 = await API.createItem("book", { inPublications: true, deleted: true }, this, 'key'); + + let response = await API.userGet( + config.userID, + "publications/items?format=versions" + ); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + assert.property(json, itemKey1); + assert.notProperty(json, itemKey2); + + // Shouldn't show with includeTrashed=1 here + response = await API.userGet( + config.userID, + "publications/items?format=versions&includeTrashed=1" + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + assert.property(json, itemKey1); + assert.notProperty(json, itemKey2); + }); + + it('test_should_include_download_details', async function () { + const file = "work/file"; + const fileContents = Helpers.getRandomUnicodeString(); + const contentType = "text/html"; + const charset = "utf-8"; + fs.writeFileSync(file, fileContents); + const hash = Helpers.md5File(file); + const filename = "test_" + fileContents; + const mtime = parseInt(fs.statSync(file).mtimeMs); + const size = fs.statSync(file).size; + + const parentItemKey = await API.createItem("book", { title: 'A', inPublications: true }, this, 'key'); + const json = await API.createAttachmentItem("imported_file", { + parentItem: parentItemKey, + inPublications: true, + contentType: contentType, + charset: charset + }, false, this, 'jsonData'); + const key = json.key; + const originalVersion = json.version; + + // Get upload authorization + API.useAPIKey(config.apiKey); + let response = await API.userPost( + config.userID, + `items/${key}/file`, + Helpers.implodeParams({ + md5: hash, + mtime: mtime, + filename: filename, + filesize: size + }), + { + 'Content-Type': 'application/x-www-form-urlencoded', + 'If-None-Match': '*' + } + ); + Helpers.assert200(response); + let jsonResponse = JSON.parse(response.data); + + toDelete.push(hash); + + // Upload to S3 + response = await HTTP.post( + jsonResponse.url, + jsonResponse.prefix + fileContents + jsonResponse.suffix, + { 'Content-Type': jsonResponse.contentType } + ); + Helpers.assert201(response); + + // Register upload + response = await API.userPost( + config.userID, + `items/${key}/file`, + `upload=${jsonResponse.uploadKey}`, + { 'Content-Type': 'application/x-www-form-urlencoded', + 'If-None-Match': '*' } + ); + Helpers.assert204(response); + const newVersion = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(newVersion), parseInt(originalVersion)); + + // Anonymous read + API.useAPIKey(''); + + // Verify attachment item metadata (JSON) + response = await API.userGet( + config.userID, + `publications/items/${key}` + ); + const responseData = JSON.parse(response.data); + const jsonData = responseData.data; + assert.equal(hash, jsonData.md5); + assert.equal(mtime, jsonData.mtime); + assert.equal(filename, jsonData.filename); + assert.equal(contentType, jsonData.contentType); + assert.equal(charset, jsonData.charset); + + // Verify download details (JSON) + Helpers.assertRegExp( + `https?://[^/]+/users/${config.userID}/publications/items/${key}/file/view`, + responseData.links.enclosure.href + ); + + // Verify attachment item metadata (Atom) + response = await API.userGet( + config.userID, + `publications/items/${key}?format=atom` + ); + const xml = API.getXMLFromResponse(response); + const hrefComp = Helpers.xpathEval(xml, '//atom:entry/atom:link[@rel="enclosure"]', true, false); + const href = hrefComp.getAttribute('href'); + // Verify download details (JSON) + Helpers.assertRegExp( + `https?://[^/]+/users/${config.userID}/publications/items/${key}/file/view`, + href + ); + + // Check access to file + const r = `https?://[^/]+/(users/${config.userID}/publications/items/${key}/file/view)`; + const exp = new RegExp(r); + const matches = href.match(exp); + const fileURL = matches[1]; + response = await API.get(fileURL); + Helpers.assert302(response); + + // Remove item from My Publications + API.useAPIKey(config.apiKey); + + responseData.data.inPublications = false; + response = await API.userPost( + config.userID, + 'items', + JSON.stringify([responseData]), + { + 'Content-Type': 'application/json' + + } + ); + Helpers.assert200ForObject(response); + + // No more access via publications URL + API.useAPIKey(); + response = await API.get(fileURL); + Helpers.assert404(response); + }); +}); diff --git a/tests/remote_js/test/3/relationTest.js b/tests/remote_js/test/3/relationTest.js new file mode 100644 index 00000000..c8838b1c --- /dev/null +++ b/tests/remote_js/test/3/relationTest.js @@ -0,0 +1,406 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('RelationsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + it('testNewItemRelations', async function () { + const relations = { + "owl:sameAs": "http://zotero.org/groups/1/items/AAAAAAAA", + "dc:relation": [ + "http://zotero.org/users/" + config.userID + "/items/AAAAAAAA", + "http://zotero.org/users/" + config.userID + "/items/BBBBBBBB", + ] + }; + const json = await API.createItem("book", { relations }, true, 'jsonData'); + + assert.equal(Object.keys(relations).length, Object.keys(json.relations).length); + + for (const [predicate, object] of Object.entries(relations)) { + if (typeof object === "string") { + assert.equal(object, json.relations[predicate]); + } + else { + for (const rel of object) { + assert.include(json.relations[predicate], rel); + } + } + } + }); + + it('testRelatedItemRelations', async function () { + const relations = { + "owl:sameAs": "http://zotero.org/groups/1/items/AAAAAAAA" + }; + + const item1JSON = await API.createItem("book", { relations: relations }, true, 'jsonData'); + const item2JSON = await API.createItem("book", null, this, 'jsonData'); + + const uriPrefix = "http://zotero.org/users/" + config.userID + "/items/"; + const item1URI = uriPrefix + item1JSON.key; + const item2URI = uriPrefix + item2JSON.key; + + // Add item 2 as related item of item 1 + relations["dc:relation"] = item2URI; + item1JSON.relations = relations; + const response = await API.userPut( + config.userID, + "items/" + item1JSON.key, + JSON.stringify(item1JSON) + ); + Helpers.assertStatusCode(response, 204); + + // Make sure it exists on item 1 + const json = (await API.getItem(item1JSON.key, true, 'json')).data; + assert.equal(Object.keys(relations).length, Object.keys(json.relations).length); + for (const [predicate, object] of Object.entries(relations)) { + assert.equal(object, json.relations[predicate]); + } + + // And item 2, since related items are bidirectional + const item2JSON2 = (await API.getItem(item2JSON.key, true, 'json')).data; + assert.equal(1, Object.keys(item2JSON2.relations).length); + assert.equal(item1URI, item2JSON2.relations["dc:relation"]); + + // Sending item 2's unmodified JSON back up shouldn't cause the item to be updated. + // Even though we're sending a relation that's technically not part of the item, + // when it loads the item it will load the reverse relations too and therefore not + // add a relation that it thinks already exists. + const response2 = await API.userPut( + config.userID, + "items/" + item2JSON.key, + JSON.stringify(item2JSON2) + ); + Helpers.assertStatusCode(response2, 204); + assert.equal(parseInt(item2JSON2.version), response2.headers["last-modified-version"][0]); + }); + + it('testRelatedItemRelationsSingleRequest', async function () { + const uriPrefix = "http://zotero.org/users/" + config.userID + "/items/"; + const item1Key = Helpers.uniqueID(); + const item2Key = Helpers.uniqueID(); + const item1URI = uriPrefix + item1Key; + const item2URI = uriPrefix + item2Key; + + const item1JSON = await API.getItemTemplate('book'); + item1JSON.key = item1Key; + item1JSON.version = 0; + item1JSON.relations['dc:relation'] = item2URI; + const item2JSON = await API.getItemTemplate('book'); + item2JSON.key = item2Key; + item2JSON.version = 0; + + const response = await API.postItems([item1JSON, item2JSON]); + Helpers.assertStatusCode(response, 200); + + // Make sure it exists on item 1 + const parsedJson = (await API.getItem(item1JSON.key, true, 'json')).data; + + assert.lengthOf(Object.keys(parsedJson.relations), 1); + assert.equal(parsedJson.relations['dc:relation'], item2URI); + + // And item 2, since related items are bidirectional + const parsedJson2 = (await API.getItem(item2JSON.key, true, 'json')).data; + assert.lengthOf(Object.keys(parsedJson2.relations), 1); + assert.equal(parsedJson2.relations['dc:relation'], item1URI); + }); + + it('testInvalidItemRelation', async function () { + let response = await API.createItem('book', { + relations: { + 'foo:unknown': 'http://zotero.org/groups/1/items/AAAAAAAA' + } + }, true, 'response'); + + Helpers.assert400ForObject(response, { message: "Unsupported predicate 'foo:unknown'" }); + + response = await API.createItem('book', { + relations: { + 'owl:sameAs': 'Not a URI' + } + }, this, 'response'); + + Helpers.assert400ForObject(response, { message: "'relations' values currently must be Zotero item URIs" }); + + response = await API.createItem('book', { + relations: { + 'owl:sameAs': ['Not a URI'] + } + }, this, 'response'); + + Helpers.assert400ForObject(response, { message: "'relations' values currently must be Zotero item URIs" }); + }); + + + it('test_should_add_a_URL_from_a_relation_with_PATCH', async function () { + const relations = { + "dc:replaces": [ + `http://zotero.org/users/${config.userID}/items/AAAAAAAA` + ] + }; + + let itemJSON = await API.createItem("book", { + relations: relations + }, true, 'jsonData'); + + relations["dc:replaces"].push(`http://zotero.org/users/${config.userID}/items/BBBBBBBB`); + + const patchJSON = { + version: itemJSON.version, + relations: relations + }; + const response = await API.userPatch( + config.userID, + `items/${itemJSON.key}`, + JSON.stringify(patchJSON) + ); + Helpers.assert204(response); + + // Make sure the value (now a string) was updated + itemJSON = (await API.getItem(itemJSON.key, true, 'json')).data; + Helpers.assertCount(Object.keys(relations).length, itemJSON.relations); + Helpers.assertCount(Object.keys(relations['dc:replaces']).length, itemJSON.relations['dc:replaces']); + assert.include(itemJSON.relations['dc:replaces'], relations['dc:replaces'][0]); + assert.include(itemJSON.relations['dc:replaces'], relations['dc:replaces'][1]); + }); + + it('test_should_remove_a_URL_from_a_relation_with_PATCH', async function () { + const relations = { + "dc:replaces": [ + `http://zotero.org/users/${config.userID}/items/AAAAAAAA`, + `http://zotero.org/users/${config.userID}/items/BBBBBBBB` + ] + }; + + let itemJSON = await API.createItem("book", { + relations: relations + }, true, 'jsonData'); + + relations["dc:replaces"] = relations["dc:replaces"].slice(0, 1); + + const patchJSON = { + version: itemJSON.version, + relations: relations + }; + const response = await API.userPatch( + config.userID, + `items/${itemJSON.key}`, + JSON.stringify(patchJSON) + ); + Helpers.assert204(response); + + // Make sure the value (now a string) was updated + itemJSON = (await API.getItem(itemJSON.key, true, 'json')).data; + assert.equal(relations['dc:replaces'][0], itemJSON.relations['dc:replaces']); + }); + + + it('testDeleteItemRelation', async function () { + const relations = { + "owl:sameAs": [ + "http://zotero.org/groups/1/items/AAAAAAAA", + "http://zotero.org/groups/1/items/BBBBBBBB" + ], + "dc:relation": "http://zotero.org/users/" + config.userID + + "/items/AAAAAAAA" + }; + + let data = await API.createItem("book", { + relations: relations + }, true, 'jsonData'); + + let itemKey = data.key; + + // Remove a relation + data.relations['owl:sameAs'] = relations['owl:sameAs'] = relations['owl:sameAs'][0]; + const response = await API.userPut( + config.userID, + "items/" + itemKey, + JSON.stringify(data) + ); + Helpers.assertStatusCode(response, 204); + + // Make sure it's gone + data = (await API.getItem(data.key, true, 'json')).data; + + assert.equal(Object.keys(relations).length, Object.keys(data.relations).length); + for (const [predicate, object] of Object.entries(relations)) { + assert.deepEqual(object, data.relations[predicate]); + } + + // Delete all + data.relations = {}; + const deleteResponse = await API.userPut( + config.userID, + "items/" + data.key, + JSON.stringify(data) + ); + Helpers.assertStatusCode(deleteResponse, 204); + + // Make sure they're gone + data = (await API.getItem(itemKey, true, 'json')).data; + assert.lengthOf(Object.keys(data.relations), 0); + }); + + it('testCircularItemRelations', async function () { + const item1Data = await API.createItem("book", {}, true, 'jsonData'); + const item2Data = await API.createItem("book", {}, true, 'jsonData'); + + item1Data.relations = { + 'dc:relation': `http://zotero.org/users/${config.userID}/items/${item2Data.key}` + }; + item2Data.relations = { + 'dc:relation': `http://zotero.org/users/${config.userID}/items/${item1Data.key}` + }; + const response = await API.postItems([item1Data, item2Data]); + Helpers.assert200ForObject(response, { index: 0 }); + Helpers.assertUnchangedForObject(response, { index: 1 }); + }); + + + it('testNewCollectionRelations', async function () { + const relationsObj = { + "owl:sameAs": "http://zotero.org/groups/1/collections/AAAAAAAA" + }; + const data = await API.createCollection("Test", + { relations: relationsObj }, true, 'jsonData'); + assert.equal(Object.keys(relationsObj).length, Object.keys(data.relations).length); + for (const [predicate, object] of Object.entries(relationsObj)) { + assert.equal(object, data.relations[predicate]); + } + }); + + it('testInvalidCollectionRelation', async function () { + const json = { + name: "Test", + relations: { + "foo:unknown": "http://zotero.org/groups/1/collections/AAAAAAAA" + } + }; + const response = await API.userPost( + config.userID, + "collections", + JSON.stringify([json]) + ); + Helpers.assert400ForObject(response, { message: "Unsupported predicate 'foo:unknown'" }); + + json.relations = { + "owl:sameAs": "Not a URI" + }; + const response2 = await API.userPost( + config.userID, + "collections", + JSON.stringify([json]) + ); + Helpers.assert400ForObject(response2, { message: "'relations' values currently must be Zotero collection URIs" }); + + json.relations = ["http://zotero.org/groups/1/collections/AAAAAAAA"]; + const response3 = await API.userPost( + config.userID, + "collections", + JSON.stringify([json]) + ); + Helpers.assert400ForObject(response3, { message: "'relations' property must be an object" }); + }); + + it('testDeleteCollectionRelation', async function () { + const relations = { + "owl:sameAs": "http://zotero.org/groups/1/collections/AAAAAAAA" + }; + let data = await API.createCollection("Test", { + relations: relations + }, true, 'jsonData'); + + // Remove all relations + data.relations = {}; + delete relations['owl:sameAs']; + const response = await API.userPut( + config.userID, + `collections/${data.key}`, + JSON.stringify(data) + ); + Helpers.assertStatusCode(response, 204); + + // Make sure it's gone + data = (await API.getCollection(data.key, true, 'json')).data; + assert.equal(Object.keys(data.relations).length, Object.keys(relations).length); + for (const key in relations) { + assert.equal(data.relations[key], relations[key]); + } + }); + + it('test_should_return_200_for_values_for_mendeleyDB_collection_relation', async function () { + const relations = { + "mendeleyDB:remoteFolderUUID": "b95b84b9-8b27-4a55-b5ea-5b98c1cac205" + }; + const data = await API.createCollection( + "Test", + { + relations: relations + }, + true, + 'jsonData' + ); + assert.equal(relations['mendeleyDB:remoteFolderUUID'], data.relations['mendeleyDB:remoteFolderUUID']); + }); + + + it('test_should_return_200_for_arrays_for_mendeleyDB_collection_relation', async function () { + const json = { + name: "Test", + relations: { + "mendeleyDB:remoteFolderUUID": ["b95b84b9-8b27-4a55-b5ea-5b98c1cac205"] + } + }; + const response = await API.userPost( + config.userID, + "collections", + JSON.stringify([json]) + ); + Helpers.assert200ForObject(response); + }); + + it('test_should_add_a_URL_to_a_relation_with_PATCH', async function () { + const relations = { + "dc:replaces": [ + "http://zotero.org/users/" + config.userID + "/items/AAAAAAAA" + ] + }; + + const itemJSON = await API.createItem("book", { + relations: relations + }, true, 'jsonData'); + + relations["dc:replaces"].push("http://zotero.org/users/" + config.userID + "/items/BBBBBBBB"); + + const patchJSON = { + version: itemJSON.version, + relations: relations + }; + const response = await API.userPatch( + config.userID, + "items/" + itemJSON.key, + JSON.stringify(patchJSON) + ); + Helpers.assert204(response); + + // Make sure the array was updated + const json = (await API.getItem(itemJSON.key, 'json')).data; + assert.equal(Object.keys(json.relations).length, Object.keys(relations).length); + assert.equal(json.relations['dc:replaces'].length, relations['dc:replaces'].length); + assert.include(json.relations['dc:replaces'], relations['dc:replaces'][0]); + assert.include(json.relations['dc:replaces'], relations['dc:replaces'][1]); + }); +}); diff --git a/tests/remote_js/test/3/schemaTest.js b/tests/remote_js/test/3/schemaTest.js new file mode 100644 index 00000000..37ef2ecb --- /dev/null +++ b/tests/remote_js/test/3/schemaTest.js @@ -0,0 +1,39 @@ +var config = require('config'); +const { API3Before, API3After } = require("../shared.js"); + +describe('SchemaTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + it('test_should_reject_download_from_old_client_for_item_using_newer_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_collection_using_legacy_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_search_using_legacy_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_item_using_legacy_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_attachment_using_legacy_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_linked_file_attachment_using_legacy_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_note_using_legacy_schema', async function () { + this.skip(); + }); + it('test_should_not_reject_download_from_old_client_for_child_note_using_legacy_schema', async function () { + this.skip(); + }); +}); diff --git a/tests/remote_js/test/3/searchTest.js b/tests/remote_js/test/3/searchTest.js new file mode 100644 index 00000000..44275924 --- /dev/null +++ b/tests/remote_js/test/3/searchTest.js @@ -0,0 +1,296 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('SearchTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + const testNewSearch = async () => { + let name = "Test Search"; + let conditions = [ + { + condition: "title", + operator: "contains", + value: "test" + }, + { + condition: "noChildren", + operator: "false", + value: "" + }, + { + condition: "fulltextContent/regexp", + operator: "contains", + value: "/test/" + } + ]; + + // DEBUG: Should fail with no version? + let response = await API.userPost( + config.userID, + "searches", + JSON.stringify([{ + name: name, + conditions: conditions + }]), + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + let libraryVersion = response.headers["last-modified-version"][0]; + let json = API.getJSONFromResponse(response); + assert.equal(Object.keys(json.successful).length, 1); + // Deprecated + assert.equal(Object.keys(json.success).length, 1); + + // Check data in write response + let data = json.successful[0].data; + assert.equal(json.successful[0].key, data.key); + assert.equal(libraryVersion, data.version); + assert.equal(libraryVersion, data.version); + assert.equal(name, data.name); + assert.isArray(data.conditions); + assert.equal(conditions.length, data.conditions.length); + for (let i = 0; i < conditions.length; i++) { + for (let key in conditions[i]) { + assert.equal(conditions[i][key], data.conditions[i][key]); + } + } + + + // Check in separate request, to be safe + let keys = Object.keys(json.successful).map(i => json.successful[i].key); + response = await API.getSearchResponse(keys); + Helpers.assertTotalResults(response, 1); + json = API.getJSONFromResponse(response); + data = json[0].data; + assert.equal(name, data.name); + assert.isArray(data.conditions); + assert.equal(conditions.length, data.conditions.length); + + for (let i = 0; i < conditions.length; i++) { + for (let key in conditions[i]) { + assert.equal(conditions[i][key], data.conditions[i][key]); + } + } + + return data; + }; + + it('testEditMultipleSearches', async function () { + const search1Name = "Test 1"; + const search1Conditions = [ + { + condition: "title", + operator: "contains", + value: "test" + } + ]; + let search1Data = await API.createSearch(search1Name, search1Conditions, this, 'jsonData'); + const search1NewName = "Test 1 Modified"; + + const search2Name = "Test 2"; + const search2Conditions = [ + { + condition: "title", + operator: "is", + value: "test2" + } + ]; + let search2Data = await API.createSearch(search2Name, search2Conditions, this, 'jsonData'); + const search2NewConditions = [ + { + condition: "title", + operator: "isNot", + value: "test1" + } + ]; + + const response = await API.userPost( + config.userID, + "searches", + JSON.stringify([ + { + key: search1Data.key, + version: search1Data.version, + name: search1NewName + }, + { + key: search2Data.key, + version: search2Data.version, + conditions: search2NewConditions + } + ]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert200(response); + const libraryVersion = response.headers["last-modified-version"][0]; + const json = API.getJSONFromResponse(response); + assert.equal(Object.keys(json.successful).length, 2); + assert.equal(Object.keys(json.success).length, 2); + + // Check data in write response + assert.equal(json.successful[0].key, json.successful[0].data.key); + assert.equal(json.successful[1].key, json.successful[1].data.key); + assert.equal(libraryVersion, json.successful[0].version); + assert.equal(libraryVersion, json.successful[1].version); + assert.equal(libraryVersion, json.successful[0].data.version); + assert.equal(libraryVersion, json.successful[1].data.version); + assert.equal(search1NewName, json.successful[0].data.name); + assert.equal(search2Name, json.successful[1].data.name); + assert.deepEqual(search1Conditions, json.successful[0].data.conditions); + assert.deepEqual(search2NewConditions, json.successful[1].data.conditions); + + // Check in separate request, to be safe + const keys = Object.keys(json.successful).map(i => json.successful[i].key); + const response2 = await API.getSearchResponse(keys); + Helpers.assertTotalResults(response2, 2); + const json2 = API.getJSONFromResponse(response2); + // POST follows PATCH behavior, so unspecified values shouldn't change + assert.equal(search1NewName, json2[0].data.name); + assert.deepEqual(search1Conditions, json2[0].data.conditions); + assert.equal(search2Name, json2[1].data.name); + assert.deepEqual(search2NewConditions, json2[1].data.conditions); + }); + + + it('testModifySearch', async function () { + let searchJson = await testNewSearch(); + + // Remove one search condition + searchJson.conditions.shift(); + + const name = searchJson.name; + const conditions = searchJson.conditions; + + let response = await API.userPut( + config.userID, + `searches/${searchJson.key}`, + JSON.stringify(searchJson), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": searchJson.version + } + ); + + Helpers.assertStatusCode(response, 204); + + searchJson = (await API.getSearch(searchJson.key, true, 'json')).data; + + assert.equal(name, searchJson.name); + assert.isArray(searchJson.conditions); + assert.equal(conditions.length, searchJson.conditions.length); + + for (let i = 0; i < conditions.length; i++) { + const condition = conditions[i]; + assert.equal(condition.field, searchJson.conditions[i].field); + assert.equal(condition.operator, searchJson.conditions[i].operator); + assert.equal(condition.value, searchJson.conditions[i].value); + } + }); + + it('testNewSearchNoName', async function () { + const conditions = [ + { + condition: 'title', + operator: 'contains', + value: 'test', + }, + ]; + const headers = { + 'Content-Type': 'application/json', + }; + const response = await API.createSearch('', conditions, headers, 'responseJSON'); + Helpers.assert400ForObject(response, { message: 'Search name cannot be empty' }); + }); + + it('testNewSearchNoConditions', async function () { + const json = await API.createSearch("Test", [], true, 'responseJSON'); + Helpers.assert400ForObject(json, { message: "'conditions' cannot be empty" }); + }); + + it('testNewSearchConditionErrors', async function () { + let json = await API.createSearch( + 'Test', + [ + { + operator: 'contains', + value: 'test' + } + ], + true, + 'responseJSON' + ); + Helpers.assert400ForObject(json, { message: "'condition' property not provided for search condition" }); + + + json = await API.createSearch( + 'Test', + [ + { + condition: '', + operator: 'contains', + value: 'test' + } + ], + true, + 'responseJSON' + ); + Helpers.assert400ForObject(json, { message: 'Search condition cannot be empty' }); + + + json = await API.createSearch( + 'Test', + [ + { + condition: 'title', + value: 'test' + } + ], + true, + 'responseJSON' + ); + Helpers.assert400ForObject(json, { message: "'operator' property not provided for search condition" }); + + + json = await API.createSearch( + 'Test', + [ + { + condition: 'title', + operator: '', + value: 'test' + } + ], + true, + 'responseJSON' + ); + Helpers.assert400ForObject(json, { message: 'Search operator cannot be empty' }); + }); + it('test_should_allow_a_search_with_emoji_values', async function () { + let response = await API.createSearch( + "🐶", // 4-byte character + [ + { + condition: "title", + operator: "contains", + value: "🐶" // 4-byte character + } + ], + true, + 'responseJSON' + ); + Helpers.assert200ForObject(response); + }); +}); diff --git a/tests/remote_js/test/3/settingsTest.js b/tests/remote_js/test/3/settingsTest.js new file mode 100644 index 00000000..fbf99ef5 --- /dev/null +++ b/tests/remote_js/test/3/settingsTest.js @@ -0,0 +1,772 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After, resetGroups } = require("../shared.js"); + +describe('SettingsTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + await resetGroups(); + }); + + after(async function () { + await API3After(); + await resetGroups(); + }); + + beforeEach(async function () { + await API.userClear(config.userID); + await API.groupClear(config.ownedPrivateGroupID); + }); + + it('testAddUserSetting', async function () { + const settingKey = "tagColors"; + const value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + const libraryVersion = await API.getLibraryVersion(); + + const json = { + value: value + }; + + // No version + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 428); + + // Version must be 0 for non-existent setting + response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "1" + } + ); + Helpers.assertStatusCode(response, 412); + + // Create + response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "0" + } + ); + Helpers.assertStatusCode(response, 204); + + // Multi-object GET + response = await API.userGet( + config.userID, + `settings` + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.headers['content-type'][0], "application/json"); + let jsonResponse = JSON.parse(response.data); + + assert.property(jsonResponse, settingKey); + assert.deepEqual(value, jsonResponse[settingKey].value); + assert.equal(parseInt(libraryVersion) + 1, jsonResponse[settingKey].version); + + // Single-object GET + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assertStatusCode(response, 200); + assert.equal(response.headers['content-type'][0], "application/json"); + jsonResponse = JSON.parse(response.data); + + assert.deepEqual(value, jsonResponse.value); + assert.equal(parseInt(libraryVersion) + 1, jsonResponse.version); + }); + + it('testAddUserSettingMultiple', async function () { + await API.userClear(config.userID); + let json = { + tagColors: { + value: [ + { + name: "_READ", + color: "#990000" + } + ] + }, + feeds: { + value: { + "http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml": { + url: "http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml", + name: "NYT > Home Page", + cleanupAfter: 2, + refreshInterval: 60 + } + } + }, + // eslint-disable-next-line camelcase + lastPageIndex_u_ABCD2345: { + value: 123 + }, + // eslint-disable-next-line camelcase + lastPageIndex_g1234567890_ABCD2345: { + value: 123 + }, + // eslint-disable-next-line camelcase + lastRead_g1234567890_ABCD2345: { + value: 1674251397 + } + }; + + let settingsKeys = Object.keys(json); + let libraryVersion = parseInt(await API.getLibraryVersion()); + + let response = await API.userPost( + config.userID, + `settings`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 204); + assert.equal(++libraryVersion, response.headers['last-modified-version']); + + // Multi-object GET + const multiObjResponse = await API.userGet( + config.userID, + `settings` + ); + Helpers.assertStatusCode(multiObjResponse, 200); + + assert.equal(multiObjResponse.headers['content-type'][0], 'application/json'); + const multiObjJson = JSON.parse(multiObjResponse.data); + for (let settingsKey of settingsKeys) { + assert.property(multiObjJson, settingsKey); + assert.deepEqual(multiObjJson[settingsKey].value, json[settingsKey].value); + assert.equal(multiObjJson[settingsKey].version, parseInt(libraryVersion)); + } + + // Single-object GET + for (let settingsKey of settingsKeys) { + response = await API.userGet( + config.userID, + `settings/${settingsKey}` + ); + + Helpers.assertStatusCode(response, 200); + Helpers.assertContentType(response, 'application/json'); + const singleObjJson = JSON.parse(response.data); + assert.exists(singleObjJson); + assert.deepEqual(json[settingsKey].value, singleObjJson.value); + assert.equal(singleObjJson.version, libraryVersion); + } + }); + + it('testAddGroupSettingMultiple', async function () { + const settingKey = "tagColors"; + const value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + // TODO: multiple, once more settings are supported + + const groupID = config.ownedPrivateGroupID; + const libraryVersion = await API.getGroupLibraryVersion(groupID); + + const json = { + [settingKey]: { + value: value + } + }; + + const response = await API.groupPost( + groupID, + `settings`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + + Helpers.assertStatusCode(response, 204); + + // Multi-object GET + const response2 = await API.groupGet( + groupID, + `settings` + ); + + Helpers.assertStatusCode(response2, 200); + assert.equal(response2.headers['content-type'][0], "application/json"); + const json2 = JSON.parse(response2.data); + assert.exists(json2); + assert.property(json2, settingKey); + assert.deepEqual(value, json2[settingKey].value); + assert.equal(parseInt(libraryVersion) + 1, json2[settingKey].version); + + // Single-object GET + const response3 = await API.groupGet( + groupID, + `settings/${settingKey}` + ); + + Helpers.assertStatusCode(response3, 200); + assert.equal(response3.headers['content-type'][0], "application/json"); + const json3 = JSON.parse(response3.data); + assert.exists(json3); + assert.deepEqual(value, json3.value); + assert.equal(parseInt(libraryVersion) + 1, json3.version); + }); + + it('testDeleteNonexistentSetting', async function () { + const response = await API.userDelete(config.userID, + `settings/nonexistentSetting`, + { "If-Unmodified-Since-Version": "0" }); + Helpers.assertStatusCode(response, 404); + }); + + it('testUnsupportedSetting', async function () { + const settingKey = "unsupportedSetting"; + let value = true; + + const json = { + value: value, + version: 0 + }; + + const response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 400, `Invalid setting '${settingKey}'`); + }); + + it('testUpdateUserSetting', async function () { + let settingKey = "tagColors"; + let value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + let libraryVersion = await API.getLibraryVersion(); + + let json = { + value: value, + version: 0 + }; + + // Create + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json.value, value); + assert.equal(parseInt(json.version), parseInt(libraryVersion) + 1); + + // Update with no change + response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json.value, value); + assert.equal(parseInt(json.version), parseInt(libraryVersion) + 1); + + let newValue = [ + { + name: "_READ", + color: "#CC9933" + } + ]; + json.value = newValue; + + // Update, no change + response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json.value, newValue); + assert.equal(parseInt(json.version), parseInt(libraryVersion) + 2); + }); + + it('testUpdateUserSettings', async function () { + let settingKey = "tagColors"; + let value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + let libraryVersion = parseInt(await API.getLibraryVersion()); + + let json = { + value: value, + version: 0 + }; + + // Create + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + assert.equal(parseInt(response.headers['last-modified-version'][0]), ++libraryVersion); + + // Check + response = await API.userGet( + config.userID, + `settings` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + assert.equal(parseInt(response.headers['last-modified-version'][0]), libraryVersion); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json[settingKey].value, value); + assert.equal(parseInt(json[settingKey].version), libraryVersion); + + // Update with no change + response = await API.userPost( + config.userID, + `settings`, + JSON.stringify({ + [settingKey]: { + value: value + } + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": libraryVersion + } + ); + + Helpers.assert204(response); + assert.equal(parseInt(response.headers['last-modified-version'][0]), libraryVersion); + // Check + response = await API.userGet( + config.userID, + `settings` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + assert.equal(parseInt(response.headers['last-modified-version'][0]), libraryVersion); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json[settingKey].value, value); + assert.equal(parseInt(json[settingKey].version), libraryVersion); + + let newValue = [ + { + name: "_READ", + color: "#CC9933" + } + ]; + + // Update + response = await API.userPost( + config.userID, + `settings`, + JSON.stringify({ + [settingKey]: { + value: newValue + } + }), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": libraryVersion + } + ); + Helpers.assert204(response); + assert.equal(parseInt(response.headers['last-modified-version'][0]), ++libraryVersion); + // Check + response = await API.userGet( + config.userID, + `settings` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + assert.equal(parseInt(response.headers['last-modified-version'][0]), libraryVersion); + json = JSON.parse(response.data); + assert.isNotNull(json); + assert.deepEqual(json[settingKey].value, newValue); + assert.equal(parseInt(json[settingKey].version), libraryVersion); + }); + + it('testUnsupportedSettingMultiple', async function () { + const settingKey = 'unsupportedSetting'; + const json = { + tagColors: { + value: { + name: '_READ', + color: '#990000' + }, + version: 0 + }, + [settingKey]: { + value: false, + version: 0 + } + }; + + const libraryVersion = await API.getLibraryVersion(); + + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { 'Content-Type': 'application/json' } + ); + Helpers.assertStatusCode(response, 400); + + // Valid setting shouldn't exist, and library version should be unchanged + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assertStatusCode(response, 404); + assert.equal(libraryVersion, await API.getLibraryVersion()); + }); + + it('testOverlongSetting', async function () { + const settingKey = "tagColors"; + const value = [ + { + name: "abcdefghij".repeat(3001), + color: "#990000" + } + ]; + + const json = { + value: value, + version: 0 + }; + + const response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 400, "'value' cannot be longer than 30000 characters"); + }); + + it('testDeleteUserSetting', async function () { + let settingKey = "tagColors"; + let value = [ + { + name: "_READ", + color: "#990000" + } + ]; + + let json = { + value: value, + version: 0 + }; + + let libraryVersion = parseInt(await API.getLibraryVersion()); + + // Create + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + + // Delete + response = await API.userDelete( + config.userID, + `settings/${settingKey}`, + { + "If-Unmodified-Since-Version": `${libraryVersion + 1}` + } + ); + Helpers.assert204(response); + + // Check + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assert404(response); + + assert.equal(libraryVersion + 2, await API.getLibraryVersion()); + }); + + it('testSettingsSince', async function () { + let libraryVersion1 = parseInt(await API.getLibraryVersion()); + let response = await API.userPost( + config.userID, + "settings", + JSON.stringify({ + tagColors: { + value: [ + { + name: "_READ", + color: "#990000" + } + ] + } + }) + ); + Helpers.assert204(response); + let libraryVersion2 = response.headers['last-modified-version'][0]; + + response = await API.userPost( + config.userID, + "settings", + JSON.stringify({ + feeds: { + value: { + "http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml": { + url: "http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml", + name: "NYT > Home Page", + cleanupAfter: 2, + refreshInterval: 60 + } + } + } + }) + ); + Helpers.assert204(response); + let libraryVersion3 = response.headers['last-modified-version'][0]; + + response = await API.userGet( + config.userID, + `settings?since=${libraryVersion1}` + ); + Helpers.assertNumResults(response, 2); + + response = await API.userGet( + config.userID, + `settings?since=${libraryVersion2}` + ); + Helpers.assertNumResults(response, 1); + + response = await API.userGet( + config.userID, + `settings?since=${libraryVersion3}` + ); + Helpers.assertNumResults(response, 0); + }); + + it('test_should_reject_lastPageLabel_in_group_library', async function () { + const settingKey = `lastPageIndex_g${config.ownedPrivateGroupID}_ABCD2345`; + const value = 1234; + + const json = { + value: value, + version: 0 + }; + + + const response = await API.groupPut( + config.ownedPrivateGroupID, + `settings/${settingKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert400(response, "lastPageIndex can only be set in user library"); + }); + + it('test_should_allow_emoji_character', async function () { + const settingKey = 'tagColors'; + const value = [ + { + name: "🐶", + color: "#990000" + } + ]; + + const json = { + value: value, + version: 0 + }; + + const response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + }); + + it('test_lastPageIndex_should_accept_integers', async function () { + let json = { + lastPageIndex_u_ABCD2345: { + value: 12 + } + }; + let response = await API.userPost( + config.userID, + "settings", + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + }); + + it('should_add_zero_integer_value_for_lastPageIndex', async function() { + const settingKey = "lastPageIndex_u_NJP24DAM"; + const value = 0; + + const libraryVersion = await API.getLibraryVersion(); + + const json = { + value: value + }; + + // Create + let response = await API.userPut( + config.userID, + `settings/${settingKey}`, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "0" + } + ); + Helpers.assert204(response); + + response = await API.userGet( + config.userID, + `settings/${settingKey}` + ); + Helpers.assert200(response); + Helpers.assertContentType(response, "application/json"); + let responseBody = JSON.parse(response.data); + assert.isNotNull(responseBody); + assert.equal(responseBody.value, value); + assert.equal(responseBody.version, parseInt(libraryVersion) + 1); + }); + + it('test_lastPageIndex_should_accept_percentages_with_one_decimal_place', async function () { + let json = { + lastPageIndex_u_ABCD2345: { + value: 12.2 + } + }; + let response = await API.userPost( + config.userID, + "settings", + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert204(response); + }); + + it('test_lastPageIndex_should_reject_percentages_with_two_decimal_places', async function () { + let json = { + lastPageIndex_u_ABCD2345: { + value: 12.23 + } + }; + let response = await API.userPost( + config.userID, + "settings", + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert400(response); + }); + + it('test_lastPageIndex_should_reject_percentages_below_0_or_above_100', async function () { + let json = { + lastPageIndex_u_ABCD2345: { + value: -1.2 + } + }; + let response = await API.userPost( + config.userID, + "settings", + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert400(response); + + json = { + lastPageIndex_u_ABCD2345: { + value: 100.1 + } + }; + response = await API.userPost( + config.userID, + "settings", + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert400(response); + }); +}); diff --git a/tests/remote_js/test/3/sortTest.js b/tests/remote_js/test/3/sortTest.js new file mode 100644 index 00000000..87e38791 --- /dev/null +++ b/tests/remote_js/test/3/sortTest.js @@ -0,0 +1,506 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After, resetGroups } = require("../shared.js"); + +describe('SortTests', function () { + this.timeout(config.timeout); + //let collectionKeys = []; + let itemKeys = []; + let childAttachmentKeys = []; + let childNoteKeys = []; + //let searchKeys = []; + + let titles = ['q', 'c', 'a', 'j', 'e', 'h', 'i']; + let names = ['m', 's', 'a', 'bb', 'ba', '', '']; + let attachmentTitles = ['v', 'x', null, 'a', null]; + let notes = [null, 'aaa', null, null, 'taf']; + + before(async function () { + await API3Before(); + await setup(); + await resetGroups(); + }); + + after(async function () { + await API3After(); + }); + + beforeEach(async function () { + API.useAPIKey(config.apiKey); + }); + + const setup = async () => { + let titleIndex = 0; + for (let i = 0; i < titles.length - 2; i++) { + const key = await API.createItem("book", { + title: titles[titleIndex], + creators: [ + { + creatorType: "author", + name: names[i] + } + ] + }, true, 'key'); + titleIndex += 1; + // Child attachments + if (attachmentTitles[i]) { + childAttachmentKeys.push(await API.createAttachmentItem( + "imported_file", { + title: attachmentTitles[i] + }, key, true, 'key')); + } + // Child notes + if (notes[i]) { + childNoteKeys.push(await API.createNoteItem(notes[i], key, true, 'key')); + } + + itemKeys.push(key); + } + // Top-level attachment + itemKeys.push(await API.createAttachmentItem("imported_file", { + title: titles[titleIndex] + }, false, null, 'key')); + titleIndex += 1; + // Top-level note + itemKeys.push(await API.createNoteItem(titles[titleIndex], false, null, 'key')); + // + // Collections + // + /*for (let i=0; i<5; i++) { + collectionKeys.push(await API.createCollection("Test", false, true, 'key')); + }*/ + + // + // Searches + // + /*for (let i=0; i<5; i++) { + searchKeys.push(await API.createSearch("Test", 'default', null, 'key')); + }*/ + }; + + it('test_sort_group_by_editedBy', async function () { + this.skip(); + // user 1 makes item + let jsonOne = await API.groupCreateItem( + config.ownedPrivateGroupID, + 'book', + { title: `title_one` }, + true, + 'jsonData' + ); + + API.useAPIKey(config.user2APIKey); + + // user 2 makes item + let jsonTwo = await API.groupCreateItem( + config.ownedPrivateGroupID, + 'book', + { title: `title_two` }, + true, + 'jsonData' + ); + + // make sure, user's one item goes first + let response = await API.get(`groups/${config.ownedPrivateGroupID}/items?sort=editedBy&format=keys`); + let sortedKeys = response.data.split('\n'); + assert.equal(sortedKeys[0], jsonOne.key); + assert.equal(sortedKeys[1], jsonTwo.key); + + // user 2 updates user1's item, and the other way around + response = await API.patch(`groups/${config.ownedPrivateGroupID}/items/${jsonOne.key}`, + JSON.stringify({ title: 'updated_by_user 2' }), + { 'If-Unmodified-Since-Version': jsonOne.version }); + + Helpers.assert204(response); + + API.useAPIKey(config.apiKey); + + response = await API.patch(`groups/${config.ownedPrivateGroupID}/items/${jsonTwo.key}`, + JSON.stringify({ title: 'updated_by_user 2' }), + { 'If-Unmodified-Since-Version': jsonTwo.version }); + Helpers.assert204(response); + + // now order should be switched + response = await API.get(`groups/${config.ownedPrivateGroupID}/items?sort=editedBy&format=keys`); + sortedKeys = response.data.split('\n'); + assert.equal(sortedKeys[0], jsonTwo.key); + assert.equal(sortedKeys[1], jsonOne.key); + }); + + it('testSortTopItemsTitle', async function () { + let response = await API.userGet( + config.userID, + "items/top?format=keys&sort=title" + ); + Helpers.assertStatusCode(response, 200); + + let keys = response.data.trim().split("\n"); + + let titlesToIndex = {}; + titles.forEach((v, i) => { + titlesToIndex[v] = i; + }); + let titlesSorted = [...titles]; + titlesSorted.sort(); + let correct = {}; + titlesSorted.forEach((title) => { + let index = titlesToIndex[title]; + // The key at position k in itemKeys should be at the same position in keys + correct[index] = keys[index]; + }); + correct = Object.keys(correct).map(key => correct[key]); + assert.deepEqual(correct, keys); + }); + + // Same thing, but with order parameter for backwards compatibility + it('testSortTopItemsTitleOrder', async function () { + let response = await API.userGet( + config.userID, + "items/top?format=keys&order=title" + ); + Helpers.assertStatusCode(response, 200); + + let keys = response.data.trim().split("\n"); + + let titlesToIndex = {}; + titles.forEach((v, i) => { + titlesToIndex[v] = i; + }); + let titlesSorted = [...titles]; + titlesSorted.sort(); + let correct = {}; + titlesSorted.forEach((title) => { + let index = titlesToIndex[title]; + correct[index] = keys[index]; + }); + correct = Object.keys(correct).map(key => correct[key]); + assert.deepEqual(correct, keys); + }); + + it('testSortTopItemsCreator', async function () { + let response = await API.userGet( + config.userID, + "items/top?format=keys&sort=creator" + ); + Helpers.assertStatusCode(response, 200); + let keys = response.data.trim().split("\n"); + let namesCopy = { ...names }; + let sortFunction = function (a, b) { + if (a === '' && b !== '') return 1; + if (b === '' && a !== '') return -1; + if (a < b) return -1; + if (a > b) return 11; + return 0; + }; + let namesEntries = Object.entries(namesCopy); + namesEntries.sort((a, b) => sortFunction(a[1], b[1])); + assert.equal(Object.keys(namesEntries).length, keys.length); + let correct = {}; + namesEntries.forEach((entry, i) => { + correct[i] = itemKeys[parseInt(entry[0])]; + }); + correct = Object.keys(correct).map(key => correct[key]); + // Check attachment and note, which should fall back to ordered added (itemID) + assert.deepEqual(correct, keys); + }); + it('testSortTopItemsCreator', async function () { + let response = await API.userGet( + config.userID, + "items/top?format=keys&sort=creator" + ); + Helpers.assertStatusCode(response, 200); + let keys = response.data.trim().split("\n"); + let namesCopy = { ...names }; + let sortFunction = function (a, b) { + if (a === '' && b !== '') return 1; + if (b === '' && a !== '') return -1; + if (a < b) return -1; + if (a > b) return 11; + return 0; + }; + let namesEntries = Object.entries(namesCopy); + namesEntries.sort((a, b) => sortFunction(a[1], b[1])); + assert.equal(Object.keys(namesEntries).length, keys.length); + let correct = {}; + namesEntries.forEach((entry, i) => { + correct[i] = itemKeys[parseInt(entry[0])]; + }); + correct = Object.keys(correct).map(key => correct[key]); + assert.deepEqual(correct, keys); + }); + + it('testSortTopItemsCreatorOrder', async function () { + let response = await API.userGet( + config.userID, + "items/top?format=keys&order=creator" + ); + Helpers.assertStatusCode(response, 200); + let keys = response.data.trim().split("\n"); + let namesCopy = { ...names }; + let sortFunction = function (a, b) { + if (a === '' && b !== '') return 1; + if (b === '' && a !== '') return -1; + if (a < b) return -1; + if (a > b) return 11; + return 0; + }; + let namesEntries = Object.entries(namesCopy); + namesEntries.sort((a, b) => sortFunction(a[1], b[1])); + assert.equal(Object.keys(namesEntries).length, keys.length); + let correct = {}; + namesEntries.forEach((entry, i) => { + correct[i] = itemKeys[parseInt(entry[0])]; + }); + correct = Object.keys(correct).map(key => correct[key]); + assert.deepEqual(correct, keys); + }); + + + it('testSortDirection', async function () { + await API.userClear(config.userID); + let dataArray = []; + + dataArray.push(await API.createItem("book", { + title: "B", + creators: [ + { + creatorType: "author", + name: "B" + } + ], + dateAdded: '2014-02-05T00:00:00Z', + dateModified: '2014-04-05T01:00:00Z' + }, this, 'jsonData')); + + dataArray.push(await API.createItem("journalArticle", { + title: "A", + creators: [ + { + creatorType: "author", + name: "A" + } + ], + dateAdded: '2014-02-04T00:00:00Z', + dateModified: '2014-01-04T01:00:00Z' + }, this, 'jsonData')); + + dataArray.push(await API.createItem("newspaperArticle", { + title: "F", + creators: [ + { + creatorType: "author", + name: "F" + } + ], + dateAdded: '2014-02-03T00:00:00Z', + dateModified: '2014-02-03T01:00:00Z' + }, this, 'jsonData')); + + dataArray.push(await API.createItem("book", { + title: "C", + creators: [ + { + creatorType: "author", + name: "C" + } + ], + dateAdded: '2014-02-02T00:00:00Z', + dateModified: '2014-03-02T01:00:00Z' + }, this, 'jsonData')); + + // Get sorted keys + dataArray.sort(function (a, b) { + return new Date(a.dateAdded) - new Date(b.dateAdded); + }); + + let keysByDateAddedAscending = dataArray.map(function (data) { + return data.key; + }); + + let keysByDateAddedDescending = [...keysByDateAddedAscending]; + keysByDateAddedDescending.reverse(); + // Ascending + let response = await API.userGet(config.userID, "items?format=keys&sort=dateAdded&direction=asc"); + Helpers.assert200(response); + assert.deepEqual(keysByDateAddedAscending, response.data.trim().split("\n")); + + response = await API.userGet(config.userID, "items?format=json&sort=dateAdded&direction=asc"); + Helpers.assert200(response); + let json = API.getJSONFromResponse(response); + let keys = json.map(val => val.key); + assert.deepEqual(keysByDateAddedAscending, keys); + + response = await API.userGet(config.userID, "items?format=atom&sort=dateAdded&direction=asc"); + Helpers.assert200(response); + let xml = API.getXMLFromResponse(response); + keys = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + assert.deepEqual(keysByDateAddedAscending, keys); + + // Ascending using old 'order'/'sort' instead of 'sort'/'direction' + response = await API.userGet(config.userID, "items?format=keys&order=dateAdded&sort=asc"); + Helpers.assert200(response); + assert.deepEqual(keysByDateAddedAscending, response.data.trim().split("\n")); + + response = await API.userGet(config.userID, "items?format=json&order=dateAdded&sort=asc"); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + keys = json.map(val => val.key); + assert.deepEqual(keysByDateAddedAscending, keys); + + response = await API.userGet(config.userID, "items?format=atom&order=dateAdded&sort=asc"); + Helpers.assert200(response); + xml = API.getXMLFromResponse(response); + keys = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + assert.deepEqual(keysByDateAddedAscending, keys); + + // Deprecated 'order'/'sort', but the wrong way + response = await API.userGet(config.userID, "items?format=keys&sort=dateAdded&order=asc"); + Helpers.assert200(response); + assert.deepEqual(keysByDateAddedAscending, response.data.trim().split("\n")); + + // Descending + //START + response = await API.userGet( + config.userID, + "items?format=keys&sort=dateAdded&direction=desc" + ); + Helpers.assert200(response); + assert.deepEqual(keysByDateAddedDescending, response.data.trim().split("\n")); + + response = await API.userGet( + config.userID, + "items?format=json&sort=dateAdded&direction=desc" + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + keys = json.map(val => val.key); + assert.deepEqual(keysByDateAddedDescending, keys); + + response = await API.userGet( + config.userID, + "items?format=atom&sort=dateAdded&direction=desc" + ); + Helpers.assert200(response); + xml = API.getXMLFromResponse(response); + keys = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + assert.deepEqual(keysByDateAddedDescending, keys); + + // Descending + response = await API.userGet( + config.userID, + "items?format=keys&order=dateAdded&sort=desc" + ); + Helpers.assert200(response); + assert.deepEqual(keysByDateAddedDescending, response.data.trim().split("\n")); + + response = await API.userGet( + config.userID, + "items?format=json&order=dateAdded&sort=desc" + ); + Helpers.assert200(response); + json = API.getJSONFromResponse(response); + keys = json.map(val => val.key); + assert.deepEqual(keysByDateAddedDescending, keys); + + response = await API.userGet( + config.userID, + "items?format=atom&order=dateAdded&sort=desc" + ); + Helpers.assert200(response); + xml = API.getXMLFromResponse(response); + keys = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + assert.deepEqual(keysByDateAddedDescending, keys); + }); + + // Sort by item type + it('test_sort_top_level_items_by_item_type', async function () { + const response = await API.userGet( + config.userID, + "items/top?sort=itemType" + ); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + const itemTypes = json.map(arr => arr.data.itemType); + const sorted = itemTypes.sort(); + assert.deepEqual(sorted, itemTypes); + }); + + it('testSortSortParamAsDirectionWithoutOrder', async function () { + const response = await API.userGet( + config.userID, + "items?format=keys&sort=asc" + ); + Helpers.assert200(response); + }); + + it('testSortDefault', async function () { + await API.userClear(config.userID); + let dataArray = []; + dataArray.push(await API.createItem("book", { + title: "B", + creators: [{ + creatorType: "author", + name: "B" + }], + dateAdded: '2014-02-05T00:00:00Z', + dateModified: '2014-04-05T01:00:00Z' + }, this, 'jsonData')); + dataArray.push(await API.createItem("journalArticle", { + title: "A", + creators: [{ + creatorType: "author", + name: "A" + }], + dateAdded: '2014-02-04T00:00:00Z', + dateModified: '2014-01-04T01:00:00Z' + }, this, 'jsonData')); + dataArray.push(await API.createItem("newspaperArticle", { + title: "F", + creators: [{ + creatorType: "author", + name: "F" + }], + dateAdded: '2014-02-03T00:00:00Z', + dateModified: '2014-02-03T01:00:00Z' + }, this, 'jsonData')); + dataArray.push(await API.createItem("book", { + title: "C", + creators: [{ + creatorType: "author", + name: "C" + }], + dateAdded: '2014-02-02T00:00:00Z', + dateModified: '2014-03-02T01:00:00Z' + }, this, 'jsonData')); + + // Get sorted keys + dataArray.sort((a, b) => { + return new Date(b.dateAdded) - new Date(a.dateAdded); + }); + const keysByDateAddedDescending = dataArray.map(data => data.key); + dataArray.sort((a, b) => { + return new Date(b.dateModified) - new Date(a.dateModified); + }); + const keysByDateModifiedDescending = dataArray.map(data => data.key); + + // Tests + let response = await API.userGet(config.userID, "items?format=keys"); + Helpers.assert200(response); + assert.deepEqual(keysByDateModifiedDescending, response.data.trim().split('\n')); + response = await API.userGet(config.userID, "items?format=json"); + Helpers.assert200(response); + const json = API.getJSONFromResponse(response); + let keys = json.map(val => val.key); + assert.deepEqual(keysByDateModifiedDescending, keys); + response = await API.userGet(config.userID, "items?format=atom"); + Helpers.assert200(response); + const xml = API.getXMLFromResponse(response); + const keysXml = Helpers.xpathEval(xml, '//atom:entry/zapi:key', false, true); + keys = keysXml.map(val => val.toString()); + assert.deepEqual(keysByDateAddedDescending, keys); + }); +}); + diff --git a/tests/remote_js/test/3/storageAdmin.js b/tests/remote_js/test/3/storageAdmin.js new file mode 100644 index 00000000..6de22ff7 --- /dev/null +++ b/tests/remote_js/test/3/storageAdmin.js @@ -0,0 +1,49 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('StorageAdminTests', function () { + this.timeout(config.timeout); + const DEFAULT_QUOTA = 300; + + before(async function () { + await API3Before(); + await setQuota(0, 0, DEFAULT_QUOTA); + }); + + after(async function () { + await API3After(); + }); + + const setQuota = async (quota, expiration, expectedQuota) => { + let response = await API.post('users/' + config.userID + '/storageadmin', + `quota=${quota}&expiration=${expiration}`, + { "content-type": 'application/x-www-form-urlencoded' }, + { + username: config.rootUsername, + password: config.rootPassword + }); + Helpers.assertStatusCode(response, 200); + let xml = API.getXMLFromResponse(response); + let xmlQuota = xml.getElementsByTagName("quota")[0].innerHTML; + assert.equal(xmlQuota, expectedQuota); + if (expiration != 0) { + const xmlExpiration = xml.getElementsByTagName("expiration")[0].innerHTML; + assert.equal(xmlExpiration, expiration); + } + }; + it('test2GB', async function () { + const quota = 2000; + const expiration = Math.floor(Date.now() / 1000) + (86400 * 365); + await setQuota(quota, expiration, quota); + }); + + it('testUnlimited', async function () { + const quota = 'unlimited'; + const expiration = Math.floor(Date.now() / 1000) + (86400 * 365); + await setQuota(quota, expiration, quota); + }); +}); diff --git a/tests/remote_js/test/3/tagTest.js b/tests/remote_js/test/3/tagTest.js new file mode 100644 index 00000000..55f18975 --- /dev/null +++ b/tests/remote_js/test/3/tagTest.js @@ -0,0 +1,936 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('TagTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + beforeEach(async function () { + await API.userClear(config.userID); + }); + + it('test_empty_tag_including_whitespace_should_be_ignored', async function () { + let json = await API.getItemTemplate("book"); + json.tags.push({ tag: "A" }); + json.tags.push({ tag: "", type: 1 }); + json.tags.push({ tag: " ", type: 1 }); + + + let response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + assert.deepEqual(json.successful[0].data.tags, [{ tag: 'A' }]); + }); + + it('test_rename_tag', async function () { + this.skip(); + let json = await API.getItemTemplate("book"); + json.tags.push({ tag: "A" }); + + let response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + assert.deepEqual(json.successful[0].data.tags, [{ tag: 'A' }]); + let libraryVersion = await API.getLibraryVersion(); + response = await API.userPost(config.userID, `tags?tag=A&tagName=B`, '{}', { 'If-Unmodified-Since-Version': libraryVersion }); + Helpers.assert204(response); + + response = await API.userGet(config.userID, `/items/${json.successful[0].key}`); + const data = JSON.parse(response.data).data; + assert.equal(data.tags[0].tag, "B"); + + response = await API.userGet(`/tags/A`); + Helpers.assert404(response); + }); + + it('test_||_escaping', async function () { + this.skip(); + let json = await API.getItemTemplate("book"); + json.tags.push({ tag: "This || That" }); + + let response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + assert.deepEqual(json.successful[0].data.tags, [{ tag: 'This || That' }]); + + response = await API.userGet(config.userID, `/items/?tag=This&format=keys`); + assert.equal(response.data, "\n"); + response = await API.userGet(config.userID, `/items/?tag=That&format=keys`); + assert.equal(response.data, "\n"); + response = await API.userGet(config.userID, '/items/?tag=This%20\\||%20That&format=keys'); + assert.equal(response.data, `${json.successful[0].key}\n`); + + let libraryVersion = await API.getLibraryVersion(); + response = await API.userDelete(config.userID, `tags?tag=This%20\\||%20That`, { "If-Unmodified-Since-Version": libraryVersion }); + Helpers.assert204(response); + + response = await API.userGet(config.userID, `/items/?tag=This%20\\||%20That&format=keys`); + assert.equal(response.data, `\n`); + }); + + it('testInvalidTagObject', async function () { + let json = await API.getItemTemplate("book"); + json.tags.push(["invalid"]); + + let headers = { "Content-Type": "application/json" }; + let response = await API.postItem(json, headers); + + Helpers.assert400ForObject(response, { message: "Tag must be an object" }); + }); + + it('test_should_add_tag_to_item', async function () { + let json = await API.getItemTemplate("book"); + json.tags.push({ tag: "A" }); + + let response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response).successful[0].data; + + json.tags.push({ tag: "C" }); + response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response).successful[0].data; + + json.tags.push({ tag: "B" }); + response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response).successful[0].data; + + json.tags.push({ tag: "D" }); + response = await API.postItem(json); + Helpers.assert200ForObject(response); + let tags = json.tags; + json = API.getJSONFromResponse(response).successful[0].data; + + assert.deepEqual(tags, json.tags); + }); + + + it('testTagSearch', async function () { + const tags1 = ["a", "aa", "b"]; + const tags2 = ["b", "c", "cc"]; + + await API.createItem("book", { + tags: tags1.map((tag) => { + return { tag: tag }; + }) + }, true, 'key'); + + await API.createItem("book", { + tags: tags2.map((tag) => { + return { tag: tag }; + }) + }, true, 'key'); + + let response = await API.userGet( + config.userID, + "tags?tag=" + tags1.join("%20||%20"), + { "Content-Type": "application/json" } + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, tags1.length); + }); + + it('testTagNewer', async function () { + // Create items with tags + await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }, true); + + const version = await API.getLibraryVersion(); + + // 'newer' shouldn't return any results + let response = await API.userGet( + config.userID, + `tags?newer=${version}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 0); + + // Create another item with tags + await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "c" } + ] + }, true); + + // 'newer' should return new tag Atom + response = await API.userGet( + config.userID, + `tags?content=json&newer=${version}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + assert.isAbove(parseInt(response.headers['last-modified-version']), parseInt(version)); + let xml = API.getXMLFromResponse(response); + let data = API.parseDataFromAtomEntry(xml); + data = JSON.parse(data.content); + assert.strictEqual(data.tag, 'c'); + assert.strictEqual(data.type, 0); + + + // 'newer' should return new tag (JSON) + response = await API.userGet( + config.userID, + `tags?newer=${version}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + assert.isAbove(parseInt(response.headers['last-modified-version']), parseInt(version)); + let json = API.getJSONFromResponse(response)[0]; + assert.strictEqual(json.tag, 'c'); + assert.strictEqual(json.meta.type, 0); + }); + + it('testMultiTagDelete', async function () { + const tags1 = ["a", "aa", "b"]; + const tags2 = ["b", "c", "cc"]; + const tags3 = ["Foo"]; + + await API.createItem("book", { + tags: tags1.map(tag => ({ tag: tag })) + }, true, 'key'); + + await API.createItem("book", { + tags: tags2.map(tag => ({ tag: tag, type: 1 })) + }, true, 'key'); + + await API.createItem("book", { + tags: tags3.map(tag => ({ tag: tag })) + }, true, 'key'); + + let libraryVersion = await API.getLibraryVersion(); + libraryVersion = parseInt(libraryVersion); + + // Missing version header + let response = await API.userDelete( + config.userID, + `tags?content=json&tag=${tags1.concat(tags2).map(tag => encodeURIComponent(tag)).join("%20||%20")}` + ); + Helpers.assertStatusCode(response, 428); + + // Outdated version header + response = await API.userDelete( + config.userID, + `tags?content=json&tag=${tags1.concat(tags2).map(tag => encodeURIComponent(tag)).join("%20||%20")}`, + { "If-Unmodified-Since-Version": `${libraryVersion - 1}` } + ); + Helpers.assertStatusCode(response, 412); + + // Delete + response = await API.userDelete( + config.userID, + `tags?content=json&tag=${tags1.concat(tags2).map(tag => encodeURIComponent(tag)).join("%20||%20")}`, + { "If-Unmodified-Since-Version": `${libraryVersion}` } + ); + Helpers.assertStatusCode(response, 204); + + // Make sure they're gone + response = await API.userGet( + config.userID, + `tags?content=json&tag=${tags1.concat(tags2, tags3).map(tag => encodeURIComponent(tag)).join("%20||%20")}` + ); + Helpers.assertStatusCode(response, 200); + Helpers.assertNumResults(response, 1); + }); + + /** + * When modifying a tag on an item, only the item itself should have its + * version updated, not other items that had (and still have) the same tag + */ + it('testTagAddItemVersionChange', async function () { + let data1 = await API.createItem("book", { + tags: [{ + tag: "a" + }, + { + tag: "b" + }] + }, true, 'jsonData'); + + let data2 = await API.createItem("book", { + tags: [{ + tag: "a" + }, + { + tag: "c" + }] + }, true, 'jsonData'); + + let version2 = data2.version; + version2 = parseInt(version2); + + // Remove tag 'a' from item 1 + data1.tags = [{ + tag: "d" + }, + { + tag: "c" + }]; + + let response = await API.postItem(data1); + Helpers.assertStatusCode(response, 200); + + // Item 1 version should be one greater than last update + let json1 = await API.getItem(data1.key, true, 'json'); + assert.equal(parseInt(json1.version), version2 + 1); + + // Item 2 version shouldn't have changed + let json2 = await API.getItem(data2.key, true, 'json'); + assert.equal(parseInt(json2.version), version2); + }); + + it('testItemTagSearch', async function () { + // Create items with tags + let key1 = await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }, true, 'key'); + + let key2 = await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "c" } + ] + }, true, 'key'); + + let checkTags = async function (tagComponent, assertingKeys = []) { + let response = await API.userGet( + config.userID, + `items?format=keys&${tagComponent}` + ); + Helpers.assertStatusCode(response, 200); + if (assertingKeys.length != 0) { + let keys = response.data.trim().split("\n"); + + assert.equal(keys.length, assertingKeys.length); + for (let assertingKey of assertingKeys) { + assert.include(keys, assertingKey); + } + } + else { + assert.isEmpty(response.data.trim()); + } + return response; + }; + + // Searches + await checkTags("tag=a", [key2, key1]); + await checkTags("tag=a&tag=c", [key2]); + await checkTags("tag=b&tag=c", []); + await checkTags("tag=b%20||%20c", [key1, key2]); + await checkTags("tag=a%20||%20b%20||%20c", [key1, key2]); + await checkTags("tag=-a"); + await checkTags("tag=-b", [key2]); + await checkTags("tag=b%20||%20c&tag=a", [key1, key2]); + await checkTags("tag=-z", [key1, key2]); + await checkTags("tag=B", [key1]); + }); + + // + + + it('test_tags_within_items_within_empty_collection', async function () { + let collectionKey = await API.createCollection("Empty collection", false, this, 'key'); + await API.createItem( + "book", + { + title: "Foo", + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }, + this, + 'key' + ); + + let response = await API.userGet( + config.userID, + "collections/" + collectionKey + "/items/top/tags" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 0); + }); + + it('test_tags_within_items', async function () { + const collectionKey = await API.createCollection("Collection", false, this, 'key'); + const item1Key = await API.createItem( + "book", + { + title: "Foo", + tags: [ + { tag: "a" }, + { tag: "g" } + ] + }, + this, + 'key' + ); + // Child note + await API.createItem( + "note", + { + note: "Test Note 1", + parentItem: item1Key, + tags: [ + { tag: "a" }, + { tag: "e" } + ] + }, + this + ); + // Another item + await API.createItem( + "book", + { + title: "Bar", + tags: [ + { tag: "b" } + ] + }, + this + ); + // Item within collection + const item4Key = await API.createItem( + "book", + { + title: "Foo", + collections: [collectionKey], + tags: [ + { tag: "a" }, + { tag: "c" }, + { tag: "g" } + ] + }, + this, + 'key' + ); + // Child note within collection + await API.createItem( + "note", + { + note: "Test Note 2", + parentItem: item4Key, + tags: [ + { tag: "a" }, + { tag: "f" } + ] + }, + this + ); + // Another item within collection + await API.createItem( + "book", + { + title: "Bar", + collections: [collectionKey], + tags: [ + { tag: "d" } + ] + }, + this + ); + + // All items, equivalent to /tags + const response = await API.userGet( + config.userID, + "items/tags" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 7); + const json = API.getJSONFromResponse(response); + assert.deepEqual( + ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + json.map(tag => tag.tag).sort() + ); + + // Top-level items + const responseTop = await API.userGet( + config.userID, + "items/top/tags" + ); + Helpers.assert200(responseTop); + Helpers.assertNumResults(responseTop, 5); + const jsonTop = API.getJSONFromResponse(responseTop); + assert.deepEqual( + ['a', 'b', 'c', 'd', 'g'], + jsonTop.map(tag => tag.tag).sort() + ); + + // All items, filtered by 'tag', equivalent to /tags + const responseTag = await API.userGet( + config.userID, + "items/tags?tag=a" + ); + Helpers.assert200(responseTag); + Helpers.assertNumResults(responseTag, 1); + const jsonTag = API.getJSONFromResponse(responseTag); + assert.deepEqual( + ['a'], + jsonTag.map(tag => tag.tag).sort() + ); + + // All items, filtered by 'itemQ' + const responseItemQ1 = await API.userGet( + config.userID, + "items/tags?itemQ=foo" + ); + Helpers.assert200(responseItemQ1); + Helpers.assertNumResults(responseItemQ1, 3); + const jsonItemQ1 = API.getJSONFromResponse(responseItemQ1); + assert.deepEqual( + ['a', 'c', 'g'], + jsonItemQ1.map(tag => tag.tag).sort() + ); + const responseItemQ2 = await API.userGet( + config.userID, + "items/tags?itemQ=bar" + ); + Helpers.assert200(responseItemQ2); + Helpers.assertNumResults(responseItemQ2, 2); + const jsonItemQ2 = API.getJSONFromResponse(responseItemQ2); + assert.deepEqual( + ['b', 'd'], + jsonItemQ2.map(tag => tag.tag).sort() + ); + const responseItemQ3 = await API.userGet( + config.userID, + "items/tags?itemQ=Test%20Note" + ); + Helpers.assert200(responseItemQ3); + Helpers.assertNumResults(responseItemQ3, 3); + const jsonItemQ3 = API.getJSONFromResponse(responseItemQ3); + assert.deepEqual( + ['a', 'e', 'f'], + jsonItemQ3.map(tag => tag.tag).sort() + ); + + // All items with the given tags + const responseItemTag = await API.userGet( + config.userID, + "items/tags?itemTag=a&itemTag=g" + ); + Helpers.assert200(responseItemTag); + Helpers.assertNumResults(responseItemTag, 3); + const jsonItemTag = API.getJSONFromResponse(responseItemTag); + assert.deepEqual( + ['a', 'c', 'g'], + jsonItemTag.map(tag => tag.tag).sort() + ); + + // Disjoint tags + const responseItemTag2 = await API.userGet( + config.userID, + "items/tags?itemTag=a&itemTag=d" + ); + Helpers.assert200(responseItemTag2); + Helpers.assertNumResults(responseItemTag2, 0); + + // Items within a collection + const responseInCollection = await API.userGet( + config.userID, + `collections/${collectionKey}/items/tags` + ); + Helpers.assert200(responseInCollection); + Helpers.assertNumResults(responseInCollection, 5); + const jsonInCollection = API.getJSONFromResponse(responseInCollection); + assert.deepEqual( + ['a', 'c', 'd', 'f', 'g'], + jsonInCollection.map(tag => tag.tag).sort() + ); + + // Top-level items within a collection + const responseTopInCollection = await API.userGet( + config.userID, + `collections/${collectionKey}/items/top/tags` + ); + Helpers.assert200(responseTopInCollection); + Helpers.assertNumResults(responseTopInCollection, 4); + const jsonTopInCollection = API.getJSONFromResponse(responseTopInCollection); + assert.deepEqual( + ['a', 'c', 'd', 'g'], + jsonTopInCollection.map(tag => tag.tag).sort() + ); + + // Search within a collection + const responseSearchInCollection = await API.userGet( + config.userID, + `collections/${collectionKey}/items/tags?itemQ=Test%20Note` + ); + Helpers.assert200(responseSearchInCollection); + Helpers.assertNumResults(responseSearchInCollection, 2); + const jsonSearchInCollection = API.getJSONFromResponse(responseSearchInCollection); + assert.deepEqual( + ['a', 'f'], + jsonSearchInCollection.map(tag => tag.tag).sort() + ); + + // Items with the given tags within a collection + const responseTagInCollection = await API.userGet( + config.userID, + `collections/${collectionKey}/items/tags?itemTag=a&itemTag=g` + ); + Helpers.assert200(responseTagInCollection); + Helpers.assertNumResults(responseTagInCollection, 3); + const jsonTagInCollection = API.getJSONFromResponse(responseTagInCollection); + assert.deepEqual( + ['a', 'c', 'g'], + jsonTagInCollection.map(tag => tag.tag).sort() + ); + }); + + it('test_should_create_a_0_tag', async function () { + let data = await API.createItem("book", { + tags: [ + { tag: "0" } + ] + }, this, 'jsonData'); + + Helpers.assertCount(1, data.tags); + assert.equal("0", data.tags[0].tag); + }); + + it('test_should_handle_negation_in_top_requests', async function () { + let key1 = await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }, this, 'key'); + let key2 = await API.createItem("book", { + tags: [ + { tag: "a" }, + { tag: "c" } + ] + }, this, 'key'); + await API.createAttachmentItem("imported_url", [], key1, this, 'jsonData'); + await API.createAttachmentItem("imported_url", [], key2, this, 'jsonData'); + let response = await API.userGet(config.userID, "items/top?format=keys&tag=-b", { + "Content-Type": "application/json" + }); + Helpers.assert200(response); + let keys = response.data.trim().split("\n"); + assert.strictEqual(keys.length, 1); + assert.include(keys, key2); + }); + + it('testTagQuery', async function () { + const tags = ["a", "abc", "bab"]; + + await API.createItem("book", { + tags: tags.map((tag) => { + return { tag }; + }) + }, this, 'key'); + + let response = await API.userGet( + config.userID, + "tags?q=ab" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 2); + + response = await API.userGet( + config.userID, + "tags?q=ab&qmode=startswith" + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + }); + + it('testTagDiacritics', async function () { + let data = await API.createItem("book", { + tags: [ + { tag: "ëtest" }, + ] + }, this, 'jsonData'); + let version = data.version; + + data.tags = [ + { tag: "ëtest" }, + { tag: "etest" }, + ]; + + let response = await API.postItem(data); + Helpers.assert200(response); + Helpers.assert200ForObject(response); + + data = await API.getItem(data.key, this, 'json'); + data = data.data; + assert.equal(version + 1, data.version); + assert.equal(2, data.tags.length); + assert.deepInclude(data.tags, { tag: "ëtest" }); + assert.deepInclude(data.tags, { tag: "etest" }); + }); + + it('test_should_change_case_of_existing_tag', async function () { + let data1 = await API.createItem("book", { + tags: [ + { tag: "a" }, + ] + }, this, 'jsonData'); + + let data2 = await API.createItem("book", { + tags: [ + { tag: "a" } + ] + }, this, 'jsonData'); + + let version = data1.version; + + data1.tags = [ + { tag: "A" }, + ]; + + let response = await API.postItem(data1); + Helpers.assert200(response); + Helpers.assert200ForObject(response); + + // Item version should be one greater than last update + data1 = (await API.getItem(data1.key, this, 'json')).data; + data2 = (await API.getItem(data2.key, this, 'json')).data; + assert.equal(version + 1, data2.version); + assert.equal(1, data1.tags.length); + assert.deepInclude(data1.tags, { tag: "A" }); + assert.deepInclude(data2.tags, { tag: "a" }); + }); + + it('testKeyedItemWithTags', async function () { + const itemKey = Helpers.uniqueID(); + const createItemData = { + key: itemKey, + version: 0, + tags: [ + { tag: "a" }, + { tag: "b" } + ] + }; + await API.createItem('book', createItemData, this, 'responseJSON'); + + const json2 = await API.getItem(itemKey, this, 'json'); + const data = json2.data; + assert.strictEqual(data.tags.length, 2); + assert.deepStrictEqual(data.tags[0], { tag: "a" }); + assert.deepStrictEqual(data.tags[1], { tag: "b" }); + }); + + it('testTagTooLong', async function () { + let tag = Helpers.uniqueID(300); + let json = await API.getItemTemplate("book"); + json.tags.push({ + tag: tag, + type: 1 + }); + let response = await API.postItem(json); + Helpers.assert413ForObject(response); + + json = API.getJSONFromResponse(response); + assert.equal(tag, json.failed[0].data.tag); + }); + + it('should add tag to item', async function () { + let json = await API.getItemTemplate("book"); + json.tags = [{ tag: "A" }]; + let response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + json = json.successful[0].data; + + json.tags.push({ tag: "C" }); + response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + json = json.successful[0].data; + + json.tags.push({ tag: "B" }); + response = await API.postItem(json); + Helpers.assert200ForObject(response); + json = API.getJSONFromResponse(response); + json = json.successful[0].data; + + json.tags.push({ tag: "D" }); + response = await API.postItem(json); + Helpers.assert200ForObject(response); + let tags = json.tags; + json = API.getJSONFromResponse(response); + json = json.successful[0].data; + + assert.deepEqual(tags, json.tags); + }); + + it('test_utf8mb4_tag', async function () { + let json = await API.getItemTemplate('book'); + json.tags.push({ + tag: '🐻', // 4-byte character + type: 0 + }); + + let response = await API.postItem(json, { 'Content-Type': 'application/json' }); + Helpers.assert200ForObject(response); + + let newJSON = API.getJSONFromResponse(response); + newJSON = newJSON.successful[0].data; + Helpers.assertCount(1, newJSON.tags); + assert.equal(json.tags[0].tag, newJSON.tags[0].tag); + }); + + it('testOrphanedTag', async function () { + let json = await API.createItem('book', { + tags: [{ tag: "a" }] + }, this, 'jsonData'); + let libraryVersion1 = json.version; + let itemKey1 = json.key; + + json = await API.createItem('book', { + tags: [{ tag: "b" }] + }, this, 'jsonData'); + + json = await API.createItem("book", { + tags: [{ tag: "b" }] + }, this, 'jsonData'); + + const response = await API.userDelete( + config.userID, + `items/${itemKey1}`, + { "If-Unmodified-Since-Version": libraryVersion1 } + ); + Helpers.assert204(response); + + const response1 = await API.userGet( + config.userID, + "tags" + ); + Helpers.assert200(response1); + Helpers.assertNumResults(response1, 1); + let json1 = API.getJSONFromResponse(response1)[0]; + assert.equal("b", json1.tag); + }); + + it('test_deleting_a_tag_should_update_a_linked_item', async function () { + let tags = ["a", "aa", "b"]; + + let itemKey = await API.createItem("book", { + tags: tags.map((tag) => { + return { tag: tag }; + }) + }, this, 'key'); + + let libraryVersion = parseInt(await API.getLibraryVersion()); + + // Make sure they're on the item + let json = await API.getItem(itemKey, this, 'json'); + let tagList = json.data.tags.map((tag) => { + return tag.tag; + }); + assert.deepEqual(tagList, tags); + + // Delete + let response = await API.userDelete( + config.userID, + "tags?tag=" + tags[0], + { "If-Unmodified-Since-Version": libraryVersion } + ); + Helpers.assert204(response); + + // Make sure they're gone from the item + response = await API.userGet( + config.userID, + "items?since=" + encodeURIComponent(libraryVersion) + ); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + json = API.getJSONFromResponse(response); + let jsonTags = json[0].data.tags.map((tag) => { + return tag.tag; + }); + assert.deepEqual( + jsonTags, + tags.slice(1) + ); + }); + + it('tests_unfiled_tags', async function () { + this.skip(); + await API.userClear(config.userID); + + let collectionKey = await API.createCollection('Test', false, this, 'key'); + await API.createItem("book", { title: 'aaa', tags: [{ tag: "unfiled" }] }, this, 'key'); + await API.createItem("book", { title: 'bbb', tags: [{ tag: "unfiled" }] }, this, 'key'); + + await API.createItem("book", { title: 'ccc', collections: [collectionKey], tags: [{ tag: "filed" }] }, this, 'key'); + let parentBookInCollection = await API.createItem("book", + { title: 'ddd', + collections: [collectionKey], + tags: [{ tag: "also_filed" }] }, + this, 'key'); + await API.createNoteItem("some note", parentBookInCollection, this, 'key'); + + let response = await API.userGet(config.userID, `items/unfiled/tags`); + Helpers.assert200(response); + Helpers.assertNumResults(response, 1); + let json = API.getJSONFromResponse(response); + Helpers.assertEquals("unfiled", json[0].tag); + }); + + it('should_include_annotation_tags_in_collection_tag_list', async function () { + this.skip(); + let collectionKey = await API.createCollection('Test', false, this, 'key'); + const itemKey = await API.createItem("book", { title: 'aaa', tags: [{ tag: "item_tag" }], collections: [collectionKey] }, this, 'key'); + const attachment = await API.createAttachmentItem( + "imported_file", + { contentType: 'application/pdf' }, + itemKey, + null, + 'jsonData' + ); + const annotationPayload = { + itemType: 'annotation', + parentItem: attachment.key, + annotationType: 'highlight', + annotationText: 'test', + annotationSortIndex: '00015|002431|00000', + annotationPosition: JSON.stringify({ + pageIndex: 1, + rects: [ + [314.4, 412.8, 556.2, 609.6] + ] + }), + tags: [{ + tag: "annotation_tag" + }] + }; + let response = await API.userPost( + config.userID, + "items", + JSON.stringify([annotationPayload]), + { "Content-Type": "application/json" } + ); + Helpers.assert200ForObject(response); + + response = await API.userGet( + config.userID, + `collections/${collectionKey}/tags` + ); + const data = API.getJSONFromResponse(response); + const tags = data.map(tag => tag.tag); + assert.include(tags, "item_tag"); + assert.include(tags, "annotation_tag"); + }); +}); diff --git a/tests/remote_js/test/3/translationTest.js b/tests/remote_js/test/3/translationTest.js new file mode 100644 index 00000000..fc6c45c9 --- /dev/null +++ b/tests/remote_js/test/3/translationTest.js @@ -0,0 +1,168 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('TranslationTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + it('testWebTranslationMultiple', async function () { + const url = 'https://zotero-static.s3.amazonaws.com/test-multiple.html'; + const title = 'Digital history: A guide to gathering, preserving, and presenting the past on the web'; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify({ + url: url + }), + { + "Content-Type": "application/json" + } + ); + Helpers.assert300(response); + const json = JSON.parse(response.data); + + const results = Object.assign({}, json.items); + const key = Object.keys(results)[0]; + const val = Object.values(results)[0]; + assert.equal('0', key); + assert.equal(title, val); + + const items = {}; + items[key] = val; + + // Missing token + response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + url: url, + items: items + }), + { + "Content-Type": "application/json" + } + ); + Helpers.assert400(response, "Token not provided with selected items"); + + // Invalid selection + const items2 = Object.assign({}, items); + const invalidKey = "12345"; + items2[invalidKey] = items2[key]; + delete items2[key]; + response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + url: url, + token: json.token, + items: items2 + }), + { + "Content-Type": "application/json" + } + ); + Helpers.assert400(response, `Index '${invalidKey}' not found for URL and token`); + + response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + url: url, + token: json.token, + items: items + }), + { + "Content-Type": "application/json" + } + ); + + Helpers.assert200(response); + Helpers.assert200ForObject(response); + const itemKey = API.getJSONFromResponse(response).success[0]; + const data = (await API.getItem(itemKey, this, 'json')).data; + assert.equal(title, data.title); + }); + + //disabled + it('testWebTranslationSingleWithChildItems', async function () { + this.skip(); + let title = 'A Clustering Approach to Identify Intergenic Non-coding RNA in Mouse Macrophages'; + + let response = await API.userPost( + config.userID, + "items", + JSON.stringify({ + url: "http://www.computer.org/csdl/proceedings/bibe/2010/4083/00/4083a001-abs.html" + }), + { + "Content-Type": "application/json" + } + ); + Helpers.assert200(response); + Helpers.assert200ForObject(response, false, 0); + Helpers.assert200ForObject(response, false, 1); + let json = API.getJSONFromResponse(response); + + // Check item + let itemKey = json.success[0]; + let data = (await API.getItem(itemKey, this, 'json')).data; + Helpers.assertEquals(title, data.title); + // NOTE: Tags currently not served via BibTeX (though available in RIS) + Helpers.assertCount(0, data.tags); + //$this->assertContains(['tag' => 'chip-seq; clustering; non-coding rna; rna polymerase; macrophage', 'type' => 1], $data['tags']); // TODO: split in translator + + // Check note + itemKey = json.success[1]; + data = (await API.getItem(itemKey, this, 'json')).data; + Helpers.assertEquals("Complete PDF document was either not available or accessible. " + + "Please make sure you're logged in to the digital library to retrieve the " + + "complete PDF document.", data.note); + }); + + it('testWebTranslationSingle', async function () { + const url = "https://forums.zotero.org"; + const title = 'Recent Discussions'; + + const response = await API.userPost( + config.userID, + "items", + JSON.stringify({ + url: url + }), + { "Content-Type": "application/json" } + ); + Helpers.assert200(response); + Helpers.assert200ForObject(response); + const json = API.getJSONFromResponse(response); + const itemKey = json.success[0]; + const data = await API.getItem(itemKey, this, 'json'); + assert.equal(title, data.data.title); + }); + + it('testWebTranslationInvalidToken', async function () { + const url = "https://zotero-static.s3.amazonaws.com/test.html"; + + const response = await API.userPost( + config.userID, + `items?key=${config.apiKey}`, + JSON.stringify({ + url: url, + token: Helpers.md5(Helpers.uniqueID()) + }), + { "Content-Type": "application/json" } + ); + Helpers.assert400(response, "'token' is valid only for item selection requests"); + }); +}); diff --git a/tests/remote_js/test/3/versionTest.js b/tests/remote_js/test/3/versionTest.js new file mode 100644 index 00000000..098438f7 --- /dev/null +++ b/tests/remote_js/test/3/versionTest.js @@ -0,0 +1,1013 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../../api3.js'); +const Helpers = require('../../helpers3.js'); +const { API3Before, API3After } = require("../shared.js"); + +describe('VersionsTests', function () { + this.timeout(config.timeout * 2); + + before(async function () { + await API3Before(); + }); + + after(async function () { + await API3After(); + }); + + const _capitalizeFirstLetter = (string) => { + return string.charAt(0).toUpperCase() + string.slice(1); + }; + + const _modifyJSONObject = (objectType, json) => { + switch (objectType) { + case "collection": + json.name = "New Name " + Helpers.uniqueID(); + return json; + case "item": + json.title = "New Title " + Helpers.uniqueID(); + return json; + case "search": + json.name = "New Name " + Helpers.uniqueID(); + return json; + default: + throw new Error("Unknown object type"); + } + }; + + const _testSingleObjectLastModifiedVersion = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let objectKey; + switch (objectType) { + case 'collection': + objectKey = await API.createCollection('Name', false, true, 'key'); + break; + case 'item': + objectKey = await API.createItem( + 'book', + { title: 'Title' }, + true, + 'key' + ); + break; + case 'search': + objectKey = await API.createSearch( + 'Name', + [ + { + condition: 'title', + operator: 'contains', + value: 'test' + } + ], + this, + 'key' + ); + break; + } + + // JSON: Make sure all three instances of the object version + // (Last-Modified-Version, 'version', and data.version) + // match the library version + let response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}` + ); + Helpers.assert200(response); + let objectVersion = response.headers["last-modified-version"][0]; + let json = API.getJSONFromResponse(response); + assert.equal(objectVersion, json.version); + assert.equal(objectVersion, json.data.version); + + + // Atom: Make sure all three instances of the object version + // (Last-Modified-Version, zapi:version, and the JSON + // {$objectType}Version property match the library version + response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}?content=json` + ); + + Helpers.assertStatusCode(response, 200); + objectVersion = parseInt(response.headers['last-modified-version'][0]); + const xml = API.getXMLFromResponse(response); + const data = API.parseDataFromAtomEntry(xml); + json = JSON.parse(data.content); + assert.equal(objectVersion, json.version); + assert.equal(objectVersion, data.version); + + response = await API.userGet( + config.userID, + `${objectTypePlural}?limit=1` + ); + Helpers.assertStatusCode(response, 200); + const libraryVersion = response.headers['last-modified-version'][0]; + assert.equal(libraryVersion, objectVersion); + _modifyJSONObject(objectType, json); + + // No If-Unmodified-Since-Version or JSON version property + delete json.version; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 428); + + // Out of date version + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json), + { + 'If-Unmodified-Since-Version': objectVersion - 1 + } + ); + Helpers.assertStatusCode(response, 412); + + // Update with version header + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json), + { + 'If-Unmodified-Since-Version': objectVersion + } + ); + Helpers.assertStatusCode(response, 204); + + // Update object with JSON version property + const newObjectVersion = parseInt(response.headers['last-modified-version'][0]); + assert.isAbove(parseInt(newObjectVersion), parseInt(objectVersion)); + _modifyJSONObject(objectType, json); + json.version = newObjectVersion; + response = await API.userPut( + config.userID, + `${objectTypePlural}/${objectKey}`, + JSON.stringify(json) + ); + Helpers.assertStatusCode(response, 204); + const newObjectVersion2 = response.headers['last-modified-version'][0]; + assert.isAbove(parseInt(newObjectVersion2), parseInt(newObjectVersion)); + + // Make sure new library version matches new object versio + response = await API.userGet( + config.userID, + `${objectTypePlural}?limit=1` + ); + Helpers.assertStatusCode(response, 200); + const newLibraryVersion = response.headers['last-modified-version'][0]; + assert.equal(parseInt(newObjectVersion2), parseInt(newLibraryVersion)); + }; + + const _testMultiObjectLastModifiedVersion = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?limit=1` + ); + + let version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + + let json; + switch (objectType) { + case 'collection': + json = {}; + json.name = "Name"; + break; + + case 'item': + json = await API.getItemTemplate("book"); + break; + + case 'search': + json = {}; + json.name = "Name"; + json.conditions = []; + json.conditions.push({ + condition: "title", + operator: "contains", + value: "test" + }); + break; + } + + // Outdated library version + const headers1 = { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version - 1 + }; + response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + headers1 + ); + + Helpers.assertStatusCode(response, 412); + + // Make sure version didn't change during failure + response = await API.userGet( + config.userID, + `${objectTypePlural}?limit=1` + ); + + assert.equal(version, parseInt(response.headers['last-modified-version'][0])); + + // Create a new object, using library timestamp + const headers2 = { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": version + }; + response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + headers2 + ); + Helpers.assertStatusCode(response, 200); + Helpers.assert200ForObject(response); + const version2 = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version2); + // Version should be incremented on new object + assert.isAbove(version2, version); + + const objectKey = API.getFirstSuccessKeyFromResponse(response); + + // Check single-object request + response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}` + ); + Helpers.assertStatusCode(response, 200); + + version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + assert.equal(version2, version); + json = API.getJSONFromResponse(response).data; + + json.key = objectKey; + // Modify object + switch (objectType) { + case 'collection': + json.name = "New Name"; + break; + + case 'item': + json.title = "New Title"; + break; + + case 'search': + json.name = "New Name"; + break; + } + + // No If-Unmodified-Since-Version or object version property + delete json.version; + response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + { + "Content-Type": "application/json" + } + ); + Helpers.assert428ForObject(response); + + // Outdated object version property + json.version = version - 1; + response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + { + "Content-Type": "application/json", + } + ); + + const message = `${_capitalizeFirstLetter(objectType)} has been modified since specified version (expected ${json.version}, found ${version2})`; + Helpers.assert412ForObject(response, { message: message }); + // Modify object, using object version property + json.version = version; + + response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + { + "Content-Type": "application/json", + } + ); + Helpers.assertStatusCode(response, 200); + Helpers.assert200ForObject(response); + // Version should be incremented on modified object + const version3 = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version3); + assert.isAbove(version3, version2); + // Check library version + response = await API.userGet( + config.userID, + `${objectTypePlural}` + ); + version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + assert.equal(version, version3); + // Check single-object request + response = await API.userGet( + config.userID, + `${objectTypePlural}/${objectKey}` + ); + version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + assert.equal(version, version3); + + // TODO: Version should be incremented on deleted item + }; + + const _testMultiObject304NotModified = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let response = await API.userGet( + config.userID, + `${objectTypePlural}` + ); + + const version = parseInt(response.headers['last-modified-version'][0]); + assert.isNumber(version); + + response = await API.userGet( + config.userID, + `${objectTypePlural}`, + { 'If-Modified-Since-Version': version } + ); + Helpers.assertStatusCode(response, 304); + }; + + const _testSinceAndVersionsFormat = async (objectType, sinceParam) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + const objArray = []; + + switch (objectType) { + case 'collection': + objArray.push(await API.createCollection("Name", false, true, 'jsonData')); + objArray.push(await API.createCollection("Name", false, true, 'jsonData')); + objArray.push(await API.createCollection("Name", false, true, 'jsonData')); + break; + + case 'item': + objArray.push(await API.createItem("book", { + title: "Title" + }, true, 'jsonData')); + objArray.push(await API.createNoteItem("Foo", objArray[0].key, true, 'jsonData')); + objArray.push(await API.createItem("book", { + title: "Title" + }, true, 'jsonData')); + objArray.push(await API.createItem("book", { + title: "Title" + }, true, 'jsonData')); + break; + + + case 'search': + objArray.push(await API.createSearch( + "Name", [{ + condition: "title", + operator: "contains", + value: "test" + }], + true, + 'jsonData' + )); + objArray.push(await API.createSearch( + "Name", [{ + condition: "title", + operator: "contains", + value: "test" + }], + true, + 'jsonData' + )); + objArray.push(await API.createSearch( + "Name", [{ + condition: "title", + operator: "contains", + value: "test" + }], + true, + 'jsonData' + )); + } + + let objects = [...objArray]; + + const firstVersion = objects[0].version; + + let response = await API.userGet( + config.userID, + `${objectTypePlural}?format=versions&${sinceParam}=${firstVersion}` + ); + Helpers.assertStatusCode(response, 200); + let json = JSON.parse(response.data); + assert.ok(json); + Helpers.assertCount(Object.keys(objects).length - 1, json); + + let keys = Object.keys(json); + + let keyIndex = 0; + if (objectType == 'item') { + assert.equal(objects[3].key, keys[0]); + assert.equal(objects[3].version, json[keys[0]]); + keyIndex += 1; + } + + assert.equal(objects[2].key, keys[keyIndex]); + assert.equal(objects[2].version, json[objects[2].key]); + assert.equal(objects[1].key, keys[keyIndex + 1]); + assert.equal(objects[1].version, json[objects[1].key]); + + // Test /top for items + if (objectType == 'item') { + response = await API.userGet( + config.userID, + `items/top?format=versions&${sinceParam}=${firstVersion}` + ); + + Helpers.assert200(response); + json = JSON.parse(response.data); + assert.ok(json); + assert.equal(objects.length - 2, Object.keys(json).length);// Exclude first item and child + + keys = Object.keys(json); + + objects = [...objArray]; + + assert.equal(objects[3].key, keys[0]); + assert.equal(objects[3].version, json[keys[0]]); + assert.equal(objects[2].key, keys[1]); + assert.equal(objects[2].version, json[keys[1]]); + } + }; + + const _testUploadUnmodified = async (objectType) => { + let objectTypePlural = API.getPluralObjectType(objectType); + let data, version, response, json; + + switch (objectType) { + case "collection": + data = await API.createCollection("Name", false, true, 'jsonData'); + break; + + case "item": + data = await API.createItem("book", { title: "Title" }, true, 'jsonData'); + break; + + case "search": + data = await API.createSearch("Name", "default", true, 'jsonData'); + break; + } + + version = data.version; + assert.notEqual(0, version); + + response = await API.userPut( + config.userID, + `${objectTypePlural}/${data.key}`, + JSON.stringify(data) + ); + + Helpers.assertStatusCode(response, 204); + assert.equal(version, response.headers["last-modified-version"][0]); + + switch (objectType) { + case "collection": + json = await API.getCollection(data.key, true, 'json'); + break; + + case "item": + json = await API.getItem(data.key, true, 'json'); + break; + + case "search": + json = await API.getSearch(data.key, true, 'json'); + break; + } + + assert.equal(version, json.version); + }; + + const _testTagsSince = async (param) => { + const tags1 = ["a", "aa", "b"]; + const tags2 = ["b", "c", "cc"]; + + const data1 = await API.createItem("book", { + tags: tags1.map((tag) => { + return { tag: tag }; + }) + }, true, 'jsonData'); + + await API.createItem("book", { + tags: tags2.map((tag) => { + return { tag: tag }; + }) + }, true, 'jsonData'); + + // Only newly added tags should be included in newer, + // not previously added tags or tags added to items + let response = await API.userGet( + config.userID, + `tags?${param}=${data1.version}` + ); + Helpers.assertNumResults(response, 2); + + // Deleting an item shouldn't update associated tag versions + response = await API.userDelete( + config.userID, + `items/${data1.key}`, + { + "If-Unmodified-Since-Version": data1.version + } + ); + Helpers.assertStatusCode(response, 204); + + response = await API.userGet( + config.userID, + `tags?${param}=${data1.version}` + ); + Helpers.assertNumResults(response, 2); + let libraryVersion = parseInt(response.headers["last-modified-version"][0]); + + response = await API.userGet( + config.userID, + `tags?${param}=${libraryVersion}` + ); + Helpers.assertNumResults(response, 0); + }; + + const _testPatchMissingObjectsWithVersion = async function (objectType) { + let objectTypePlural = API.getPluralObjectType(objectType); + let json = await API.createUnsavedDataObject(objectType); + json.key = 'TPMBJSWV'; + json.version = 123; + let response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert404ForObject( + response, + `${objectType} doesn't exist (expected version 123; use 0 instead)` + ); + }; + + const _testPatchMissingObjectWithVersion0Header = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + const json = await API.createUnsavedDataObject(objectType); + const response = await API.userPatch( + config.userID, + `${objectTypePlural}/TPMBWVZH`, + JSON.stringify(json), + { 'Content-Type': 'application/json', 'If-Unmodified-Since-Version': '0' }, + ); + Helpers.assert204(response); + }; + + const _testPatchExistingObjectsWithOldVersionProperty = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + + const key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + json.key = key; + json.version = 1; + + const response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert412ForObject(response); + }; + + const _testPatchMissingObjectWithVersionHeader = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + const json = await API.createUnsavedDataObject(objectType); + const response = await API.userPatch( + config.userID, + `${objectTypePlural}/TPMBJWVH`, + JSON.stringify(json), + { "Content-Type": "application/json", "If-Unmodified-Since-Version": "123" } + ); + Helpers.assert404(response); + }; + + const _testPatchExistingObjectWithOldVersionProperty = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + + const key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + json.version = 1; + + const response = await API.userPatch( + config.userID, + `${objectTypePlural}/${key}`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert412(response); + }; + + const _testPatchExistingObjectsWithoutVersionWithHeader = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + + const existing = await API.createDataObject(objectType, null, null, 'json'); + const key = existing.key; + let json = await API.createUnsavedDataObject(objectType); + json.key = key; + + const response = await API.userPost( + config.userID, + `${objectTypePlural}`, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert428ForObject(response); + }; + + const _testPatchMissingObjectsWithVersion0Property = async function (objectType) { + let objectTypePlural = API.getPluralObjectType(objectType); + let json = await API.createUnsavedDataObject(objectType); + json.key = 'TPMSWVZP'; + json.version = 0; + + let response = await API.userPost( + config.userID, + objectTypePlural, + JSON.stringify([json]), + { 'Content-Type': 'application/json' }); + Helpers.assert200ForObject(response); + + // POST with version > 0 to a missing object is a 404 for that object + }; + + const _testPatchExistingObjectWithVersion0Property = async function (objectType) { + let objectTypePlural = API.getPluralObjectType(objectType); + + let key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + json.version = 0; + + let response = await API.userPatch( + config.userID, + `${objectTypePlural}/${key}`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert412(response); + }; + + const _testPatchMissingObjectWithVersionProperty = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + + let json = await API.createUnsavedDataObject(objectType); + json.version = 123; + + const response = await API.userPatch( + config.userID, + `${objectTypePlural}/TPMBJWVP`, + JSON.stringify(json), + { "Content-Type": "application/json" } + ); + Helpers.assert404(response); + }; + + const _testPatchExistingObjectsWithVersion0Property = async (objectType) => { + let objectTypePlural = API.getPluralObjectType(objectType); + + let key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + json.key = key; + json.version = 0; + + let response = await API.userPost( + config.userID, + objectTypePlural, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert412ForObject(response); + }; + + const _testPostExistingLibraryWithVersion0Header = async function (objectType) { + let objectTypePlural = API.getPluralObjectType(objectType); + + let json = await API.createUnsavedDataObject(objectType); + + let response = await API.userPost( + config.userID, + objectTypePlural, + JSON.stringify([json]), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "0" + } + ); + Helpers.assert412(response); + }; + + const _testPatchExistingObjectWithVersion0Header = async function (objectType) { + const objectTypeName = API.getPluralObjectType(objectType); + let key = await API.createDataObject(objectType, null, null, 'key'); + const json = await API.createUnsavedDataObject(objectType); + const headers = { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "0" + }; + let response = await API.userPatch( + config.userID, + `${objectTypeName}/${key}`, + JSON.stringify(json), + headers + ); + Helpers.assert412(response); + }; + + const _testPatchExistingObjectWithoutVersion = async function (objectType) { + let objectTypePlural = API.getPluralObjectType(objectType); + let key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + let headers = { "Content-Type": "application/json" }; + + let response = await API.userPatch( + config.userID, + `${objectTypePlural}/${key}`, + JSON.stringify(json), + headers + ); + Helpers.assert428(response); + }; + + const _testPatchExistingObjectWithOldVersionHeader = async function (objectType) { + const objectTypePlural = API.getPluralObjectType(objectType); + + let key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + + let headers = { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": "1" + }; + + let response = await API.userPatch( + config.userID, + `${objectTypePlural}/${key}`, + JSON.stringify(json), + headers + ); + + Helpers.assert412(response); + }; + + const _testPatchMissingObjectWithVersion0Property = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + + let json = await API.createUnsavedDataObject(objectType); + json.version = 0; + + let response = await API.userPatch( + config.userID, + `${objectTypePlural}/TPMBWVZP`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert204(response); + }; + + const _testPatchMissingObjectWithoutVersion = async function (objectType) { + let objectTypePlural = API.getPluralObjectType(objectType); + let json = await API.createUnsavedDataObject(objectType); + let response = await API.userPatch( + config.userID, + `${objectTypePlural}/TPMBJWNV`, + JSON.stringify(json), + { + "Content-Type": "application/json" + } + ); + Helpers.assert404(response); + }; + + const _testPatchExistingObjectsWithoutVersionWithoutHeader = async (objectType) => { + const objectTypePlural = API.getPluralObjectType(objectType); + let key = await API.createDataObject(objectType, null, null, 'key'); + let json = await API.createUnsavedDataObject(objectType); + json.key = key; + let response = await API.userPost( + config.userID, + objectTypePlural, + JSON.stringify([json]), + { "Content-Type": "application/json" } + ); + Helpers.assert428ForObject(response); + }; + + + it('testTagsSince', async function () { + await _testTagsSince('since'); + await API.userClear(config.userID); + await _testTagsSince('newer'); + }); + + it('testSingleObjectLastModifiedVersion', async function () { + await _testSingleObjectLastModifiedVersion('collection'); + await _testSingleObjectLastModifiedVersion('item'); + await _testSingleObjectLastModifiedVersion('search'); + }); + + it('testMultiObjectLastModifiedVersion', async function () { + await _testMultiObjectLastModifiedVersion('collection'); + await _testMultiObjectLastModifiedVersion('item'); + await _testMultiObjectLastModifiedVersion('search'); + }); + + it('testMultiObject304NotModified', async function () { + await _testMultiObject304NotModified('collection'); + await _testMultiObject304NotModified('item'); + await _testMultiObject304NotModified('search'); + await _testMultiObject304NotModified('setting'); + await _testMultiObject304NotModified('tag'); + }); + + it('testSinceAndVersionsFormat', async function () { + await _testSinceAndVersionsFormat('collection', 'since'); + await _testSinceAndVersionsFormat('item', 'since'); + await _testSinceAndVersionsFormat('search', 'since'); + await API.userClear(config.userID); + await _testSinceAndVersionsFormat('collection', 'newer'); + await _testSinceAndVersionsFormat('item', 'newer'); + await _testSinceAndVersionsFormat('search', 'newer'); + }); + + it('testUploadUnmodified', async function () { + await _testUploadUnmodified('collection'); + await _testUploadUnmodified('item'); + await _testUploadUnmodified('search'); + }); + + it('test_should_include_library_version_for_412', async function () { + let json = await API.createItem("book", [], this, 'json'); + let libraryVersion = json.version; + json.data.version--; + let response = await API.userPut( + config.userID, + "items/" + json.key, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": (json.version - 1) + } + ); + Helpers.assert412(response); + assert.equal(libraryVersion, response.headers['last-modified-version'][0]); + }); + + it('testPatchExistingObjectWithOldVersionHeader', async function () { + await _testPatchExistingObjectWithOldVersionHeader('collection'); + await _testPatchExistingObjectWithOldVersionHeader('item'); + await _testPatchExistingObjectWithOldVersionHeader('search'); + }); + + it('testPatchMissingObjectWithVersionHeader', async function () { + await _testPatchMissingObjectWithVersionHeader('collection'); + await _testPatchMissingObjectWithVersionHeader('item'); + await _testPatchMissingObjectWithVersionHeader('search'); + }); + + it('testPostToSettingsWithOutdatedVersionHeader', async function () { + let libraryVersion = await API.getLibraryVersion(); + // Outdated library version + let response = await API.userPost( + config.userID, + "settings", + JSON.stringify({}), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": (libraryVersion - 1) + } + ); + Helpers.assert412(response); + }); + + it('testPatchExistingObjectsWithOldVersionProperty', async function () { + await _testPatchExistingObjectsWithOldVersionProperty('collection'); + await _testPatchExistingObjectsWithOldVersionProperty('item'); + await _testPatchExistingObjectsWithOldVersionProperty('search'); + }); + + it('testPatchExistingObjectsWithoutVersionWithoutHeader', async function () { + await _testPatchExistingObjectsWithoutVersionWithoutHeader('collection'); + await _testPatchExistingObjectsWithoutVersionWithoutHeader('item'); + await _testPatchExistingObjectsWithoutVersionWithoutHeader('search'); + }); + + it('testPatchMissingObjectWithVersion0Header', async function () { + await _testPatchMissingObjectWithVersion0Header('collection'); + await _testPatchMissingObjectWithVersion0Header('item'); + await _testPatchMissingObjectWithVersion0Header('search'); + }); + + it('testPatchExistingObjectsWithoutVersionWithHeader', async function () { + await _testPatchExistingObjectsWithoutVersionWithHeader('collection'); + await _testPatchExistingObjectsWithoutVersionWithHeader('item'); + await _testPatchExistingObjectsWithoutVersionWithHeader('search'); + }); + + it('testPatchMissingObjectWithoutVersion', async function () { + await _testPatchMissingObjectWithoutVersion('collection'); + await _testPatchMissingObjectWithoutVersion('item'); + await _testPatchMissingObjectWithoutVersion('search'); + }); + + it('test_should_not_include_library_version_for_400', async function () { + let json = await API.createItem("book", [], this, 'json'); + let response = await API.userPut( + config.userID, + "items/" + json.key, + JSON.stringify(json), + { + "Content-Type": "application/json", + "If-Unmodified-Since-Version": (json.version - 1) + } + ); + Helpers.assert400(response); + assert.notOk(response.headers['last-modified-version']); + }); + + it('testPatchMissingObjectsWithVersion', async function () { + await _testPatchMissingObjectsWithVersion('collection'); + await _testPatchMissingObjectsWithVersion('item'); + await _testPatchMissingObjectsWithVersion('search'); + }); + + it('testPatchExistingObjectWithVersion0Property', async function () { + await _testPatchExistingObjectWithVersion0Property('collection'); + await _testPatchExistingObjectWithVersion0Property('item'); + await _testPatchExistingObjectWithVersion0Property('search'); + }); + + it('testPatchMissingObjectsWithVersion0Property', async function () { + await _testPatchMissingObjectsWithVersion0Property('collection'); + await _testPatchMissingObjectsWithVersion0Property('item'); + await _testPatchMissingObjectsWithVersion0Property('search'); + }); + + it('testPatchExistingObjectWithoutVersion', async function () { + await _testPatchExistingObjectWithoutVersion('search'); + }); + + it('testPostExistingLibraryWithVersion0Header', async function () { + await _testPostExistingLibraryWithVersion0Header('collection'); + await _testPostExistingLibraryWithVersion0Header('item'); + await _testPostExistingLibraryWithVersion0Header('search'); + }); + + it('testPatchExistingObjectWithVersion0Header', async function () { + await _testPatchExistingObjectWithVersion0Header('collection'); + await _testPatchExistingObjectWithVersion0Header('item'); + await _testPatchExistingObjectWithVersion0Header('search'); + }); + + it('testPatchMissingObjectWithVersionProperty', async function () { + await _testPatchMissingObjectWithVersionProperty('collection'); + await _testPatchMissingObjectWithVersionProperty('item'); + await _testPatchMissingObjectWithVersionProperty('search'); + }); + + it('testPatchExistingObjectWithOldVersionProperty', async function () { + await _testPatchExistingObjectWithOldVersionProperty('collection'); + await _testPatchExistingObjectWithOldVersionProperty('item'); + await _testPatchExistingObjectWithOldVersionProperty('search'); + }); + + it('testPatchExistingObjectsWithVersion0Property', async function () { + await _testPatchExistingObjectsWithVersion0Property('collection'); + await _testPatchExistingObjectsWithVersion0Property('item'); + await _testPatchExistingObjectsWithVersion0Property('search'); + }); + + it('testPatchMissingObjectWithVersion0Property', async function () { + await _testPatchMissingObjectWithVersion0Property('collection'); + await _testPatchMissingObjectWithVersion0Property('item'); + await _testPatchMissingObjectWithVersion0Property('search'); + }); +}); diff --git a/tests/remote_js/test/general.js b/tests/remote_js/test/general.js new file mode 100644 index 00000000..ca2e6f00 --- /dev/null +++ b/tests/remote_js/test/general.js @@ -0,0 +1,201 @@ +const chai = require('chai'); +const assert = chai.assert; +var config = require('config'); +const API = require('../api3.js'); +const Helpers = require('../helpers3.js'); +const { API3Before, API3After } = require("./shared.js"); +const HTTP = require("../httpHandler"); + +describe('GeneralTests', function () { + this.timeout(config.timeout); + + before(async function () { + await API3Before(); + API.useAPIVersion(false); + }); + + after(async function () { + await API3After(); + }); + + beforeEach(async function () { + API.useAPIKey(config.apiKey); + }); + + + it('test404Compression', async function () { + const response = await API.get("invalidurl"); + Helpers.assert404(response); + Helpers.assertCompression(response); + }); + + it('testAPIVersionHeader', async function () { + let minVersion = 1; + let maxVersion = 3; + let defaultVersion = 3; + let response; + + for (let i = minVersion; i <= maxVersion; i++) { + response = await API.userGet(config.userID, "items?format=keys&limit=1", + { "Zotero-API-Version": i } + ); + Helpers.assert200(response); + assert.equal(i, response.headers["zotero-api-version"][0]); + } + + // Default + response = await API.userGet(config.userID, "items?format=keys&limit=1"); + Helpers.assert200(response); + assert.equal(defaultVersion, response.headers["zotero-api-version"][0]); + }); + + it('test200Compression', async function () { + const response = await API.get('itemTypes'); + Helpers.assert200(response); + Helpers.assertCompression(response); + }); + + it('testAuthorization', async function () { + let apiKey = config.apiKey; + API.useAPIKey(false); + + // Zotero-API-Key header + let response = await API.userGet( + config.userID, + "items", + { + "Zotero-API-Key": apiKey + } + ); + Helpers.assert200(response); + + // Authorization header + response = await API.userGet( + config.userID, + "items", + { + Authorization: "Bearer " + apiKey + } + ); + Helpers.assert200(response); + + // Query parameter + response = await API.userGet( + config.userID, + "items?key=" + apiKey + ); + Helpers.assert200(response); + + // Zotero-API-Key header and query parameter + response = await API.userGet( + config.userID, + "items?key=" + apiKey, + { + "Zotero-API-Key": apiKey + } + ); + Helpers.assert200(response); + + // No key + response = await API.userGet( + config.userID, + "items" + ); + Helpers.assert403(response); + + // Zotero-API-Key header and empty key (which is still an error) + response = await API.userGet( + config.userID, + "items?key=", + { + "Zotero-API-Key": apiKey + } + ); + Helpers.assert400(response); + + // Zotero-API-Key header and incorrect Authorization key (which is ignored) + response = await API.userGet( + config.userID, + "items", + { + "Zotero-API-Key": apiKey, + Authorization: "Bearer invalidkey" + } + ); + Helpers.assert200(response); + + // Zotero-API-Key header and key mismatch + response = await API.userGet( + config.userID, + "items?key=invalidkey", + { + "Zotero-API-Key": apiKey + } + ); + Helpers.assert400(response); + + // Invalid Bearer format + response = await API.userGet( + config.userID, + "items", + { + Authorization: "Bearer key=" + apiKey + } + ); + Helpers.assert400(response); + + // Ignored OAuth 1.0 header, with key query parameter + response = await API.userGet( + config.userID, + "items?key=" + apiKey, + { + Authorization: 'OAuth oauth_consumer_key="aaaaaaaaaaaaaaaaaaaa"' + } + ); + Helpers.assert200(response); + + // Ignored OAuth 1.0 header, with no key query parameter + response = await API.userGet( + config.userID, + "items", + { + Authorization: 'OAuth oauth_consumer_key="aaaaaaaaaaaaaaaaaaaa"' + } + ); + Helpers.assert403(response); + }); + + it('testAPIVersionParameter', async function () { + let minVersion = 1; + let maxVersion = 3; + + for (let i = minVersion; i <= maxVersion; i++) { + const response = await API.userGet( + config.userID, + 'items?format=keys&limit=1&v=' + i + ); + assert.equal(i, response.headers['zotero-api-version'][0]); + } + }); + + it('testCORS', async function () { + let response = await HTTP.options(config.apiURLPrefix, { Origin: "http://example.com" }); + Helpers.assert200(response); + assert.equal('', response.data); + assert.equal('*', response.headers['access-control-allow-origin'][0]); + }); + + it('test204NoCompression', async function () { + let json = await API.createItem("book", [], null, 'jsonData'); + let response = await API.userDelete( + config.userID, + `items/${json.key}`, + { + "If-Unmodified-Since-Version": json.version + } + ); + Helpers.assert204(response); + Helpers.assertNoCompression(response); + Helpers.assertContentLength(response, 0); + }); +}); diff --git a/tests/remote_js/test/shared.js b/tests/remote_js/test/shared.js new file mode 100644 index 00000000..28f1c061 --- /dev/null +++ b/tests/remote_js/test/shared.js @@ -0,0 +1,51 @@ +var config = require('config'); +const API = require('../api2.js'); +const API3 = require('../api3.js'); +const { resetGroups } = require("../groupsSetup.js"); + +module.exports = { + + resetGroups: async () => { + await resetGroups(); + }, + + + API1Before: async () => { + const credentials = await API.login(); + config.apiKey = credentials.user1.apiKey; + config.user2APIKey = credentials.user2.apiKey; + await API.useAPIVersion(1); + await API.userClear(config.userID); + }, + API1After: async () => { + await API.userClear(config.userID); + }, + + API2Before: async () => { + const credentials = await API.login(); + config.apiKey = credentials.user1.apiKey; + config.user2APIKey = credentials.user2.apiKey; + await API.useAPIVersion(2); + await API.setKeyOption(config.userID, config.apiKey, 'libraryNotes', 1); + await API.userClear(config.userID); + }, + API2After: async () => { + await API.userClear(config.userID); + }, + + API3Before: async () => { + const credentials = await API3.login(); + config.apiKey = credentials.user1.apiKey; + config.user2APIKey = credentials.user2.apiKey; + await API3.useAPIVersion(3); + await API3.useAPIKey(config.apiKey); + await API3.resetSchemaVersion(); + await API3.setKeyUserPermission(config.apiKey, 'notes', true); + await API3.setKeyUserPermission(config.apiKey, 'write', true); + await API3.userClear(config.userID); + }, + API3After: async () => { + await API3.useAPIKey(config.apiKey); + await API3.userClear(config.userID); + } +};