├── .babelrc ├── .eslintrc ├── .gitignore ├── .npm └── scripts │ ├── build │ ├── postinstall │ └── test ├── .npmignore ├── .vimrc ├── LICENSE ├── README.md ├── package.json ├── src ├── exceptions.js ├── index.js ├── lang.js ├── models.js ├── querystring.js ├── rest.js ├── sort.js ├── string.js └── xml.js └── tests ├── bootstrap.js ├── fixtures ├── combination-47.xml ├── manufacturer-1.xml ├── manufacturers.xml ├── product-10.xml ├── product-8-images.xml ├── product-8.xml ├── product-9.xml ├── product-option-values-25.xml ├── products.xml ├── stock-available-80.xml ├── stock-availables.xml └── valid.xml ├── functional └── .gitignore └── unit ├── exceptions.spec.js ├── lang.spec.js ├── models ├── Model.spec.js └── Product.spec.js ├── querystring.spec.js ├── rest ├── Client.spec.js └── resources │ ├── Images.spec.js │ ├── Manufacturers.spec.js │ ├── Products.spec.js │ └── Resource.spec.js ├── sort.spec.js └── xml.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": [ 4 | "add-module-exports" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "strict": 0 5 | }, 6 | "parserOptions": { 7 | "sourceType": "module", 8 | "allowImportExportEverywhere": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | 4 | /babel 5 | /eslint 6 | /istanbul 7 | /mocha 8 | /nodemon 9 | 10 | npm-debug.log 11 | 12 | *.swp 13 | *.swo 14 | nohup.* 15 | -------------------------------------------------------------------------------- /.npm/scripts/build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | SCRIPT_DIR=$(cd $(dirname "${BASH_SOURCE[0]}") && pwd) 5 | PROJECT_ROOT="$SCRIPT_DIR/../.." 6 | 7 | cd $PROJECT_ROOT && ./babel src --out-dir dist 8 | -------------------------------------------------------------------------------- /.npm/scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | SCRIPT_DIR=$(cd $(dirname "${BASH_SOURCE[0]}") && pwd) 5 | PROJECT_ROOT="$SCRIPT_DIR/../.." 6 | 7 | BINARIES=(babel eslint istanbul mocha nodemon) 8 | 9 | for binary in ${BINARIES[@]}; do 10 | if [ ! -L "$PROJECT_ROOT/$binary" ]; then 11 | $(cd "$PROJECT_ROOT" && ln -s "node_modules/.bin/$binary") 12 | fi 13 | done 14 | -------------------------------------------------------------------------------- /.npm/scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | SCRIPT_DIR=$(cd $(dirname "${BASH_SOURCE[0]}") && pwd) 5 | PROJECT_ROOT="$SCRIPT_DIR/../.." 6 | 7 | cd "$PROJECT_ROOT" 8 | 9 | NODE_ENV=test ./node_modules/.bin/_mocha \ 10 | --compilers js:babel-core/register \ 11 | --timeout 15000 \ 12 | -r tests/bootstrap.js \ 13 | --recursive \ 14 | tests/unit \ 15 | tests/functional 16 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itconsultis/prestashop-api-client/787ff125506600bb719c239f46de16ac974e3967/.npmignore -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | sy on 2 | set modeline 3 | set backspace=indent,eol,start 4 | set ts=2 5 | set sw=2 6 | set sts=2 7 | set et 8 | set nowrap 9 | set ruler 10 | set smartindent 11 | set wildmode=longest,list 12 | set wildmenu 13 | set incsearch 14 | set cursorline 15 | 16 | hi CursorLine term=bold cterm=bold guibg=Grey40 17 | 18 | au FileType javascript setlocal ts=2 sw=2 sts=2 et 19 | au FileType yaml setlocal ts=2 sw=2 sts=2 et 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 by IT Consultis 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PrestaShop API Client 2 | 3 | This is a server-side library that exposes the PrestaShop web service 4 | as resource and model abstractions. It allows web service consumers to be 5 | unaware of the web service's HTTP interface, or how to deal with XML payloads. 6 | 7 | ### Basic usage 8 | 9 | Create a client instance 10 | 11 | ```javascript 12 | import { rest } from 'prestashop-api-client'; 13 | 14 | const client = new rest.Client({ 15 | language: 'en', 16 | languages: { 17 | 'en': 1, 18 | 'es': 2, 19 | }, 20 | webservice: { 21 | key: 'YOUR_PRESTASHOP_API_KEY', 22 | scheme: 'https', 23 | host: 'your-prestashop-domain', 24 | root: '/api', 25 | }, 26 | }); 27 | ``` 28 | 29 | Change the current client language 30 | ```javascript 31 | client.setLanguageIso('en'); 32 | ``` 33 | 34 | Access a resource 35 | ```javascript 36 | const resource = client.resource('products'); 37 | ``` 38 | 39 | Retrieve all models exposed by a resource 40 | 41 | ```javascript 42 | resource.list().then((models) => { 43 | // models is an Array containing Model instances 44 | }); 45 | ``` 46 | 47 | Retrieve a single model from a resource by its ID 48 | ```javascript 49 | resource.get(id).then((model) => { 50 | // model is a Model instance 51 | }); 52 | ``` 53 | 54 | 55 | ### Resources 56 | 57 | - products 58 | - images 59 | - manufacturers 60 | - combinations 61 | - stock_availables 62 | - product_option_values 63 | 64 | ### Models 65 | 66 | - Product 67 | - Image 68 | - Manufacturer 69 | - Combination 70 | - StockAvailable 71 | - ProductOptionValue 72 | 73 | 74 | ### Model relations 75 | 76 | Some concrete Models implement methods that return a Resource. When a Model 77 | returns a Resource this way, that resource acts on objects that are 78 | *related to that model*. 79 | 80 | 81 | `Product` 82 | 83 | ```javascript 84 | // get an Images resource that exposes Image models related to the Product 85 | product.images() 86 | 87 | // get Image models related to the Product 88 | product.images().list() 89 | 90 | // get a Manufacturers resource 91 | product.manufacturer() 92 | 93 | // get the related Manufacturer model 94 | product.manufacturer().first() 95 | 96 | // get a Combinations resource 97 | product.combinations() 98 | 99 | // get Combination models related to the Product 100 | product.combinations().list() 101 | ``` 102 | 103 | ### Client options 104 | 105 | `languages` 106 | 107 | A dictionary that maps ISO-639-1 language codes to PrestaShop language ids. 108 | 109 | `language` 110 | 111 | The language to select when parsing translatable attributes. 112 | 113 | `webservice` 114 | 115 | HTTP request parameters 116 | 117 | key HTTP Basic Auth username 118 | scheme defaults to "https" 119 | host your PrestaShop host 120 | root the api root path; defaults to "/api" 121 | 122 | `logger` 123 | 124 | A logger instance. Defaults to dummy logger that doesn't log anything. 125 | 126 | 127 | ### Contributing 128 | 129 | 1. Fork `develop` branch 130 | 2. Push changes to your fork. 131 | 3. Submit a pull request. 132 | 133 | 134 | ### License 135 | 136 | MIT 137 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prestashop-api-client", 3 | "version": "0.3.11", 4 | "description": "PrestaShop API client", 5 | "homepage": "https://github.com/itconsultis/prestashop-api-client", 6 | "bugs": "https://github.com/itconsultis/prestashop-api-client/issues", 7 | "keywords": [ 8 | "prestashop" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:itconsultis/prestashop-api-client.git" 13 | }, 14 | "main": "./dist/index.js", 15 | "scripts": { 16 | "postinstall": ".npm/scripts/postinstall", 17 | "test": ".npm/scripts/test", 18 | "coverage": "./istanbul cover npm test", 19 | "start": "./nodemon --ignore ./dist -x 'npm test || true'", 20 | "prepublish": "npm config set registry 'https://registry.npmjs.org' && npm test && .npm/scripts/build", 21 | "postpublish": "git checkout develop" 22 | }, 23 | "author": "IT Consultis parseInt(v, 10); 8 | coerce.string = String; 9 | coerce.bool = (v) => !empty(coerce.integer(v)); 10 | coerce.number = Number; 11 | 12 | 13 | /** 14 | * Given an Object or Map, return a list of two-element tuples. Each tuple is 15 | * an Array that contains a key at index 0, and the corresponding value at 16 | * index 1. 17 | * @param {Object|Map} input 18 | * @return {Array} 19 | */ 20 | export const tuples = lang.tuples = (input) => { 21 | let output = []; 22 | 23 | if (input instanceof Map) { 24 | for (let [k, v] of input.entries()) { 25 | output.push([k, v]); 26 | } 27 | } 28 | else { 29 | for (let prop in input) { 30 | if (input.hasOwnProperty(prop)) { 31 | output.push([prop, input[prop]]); 32 | } 33 | } 34 | } 35 | 36 | return output; 37 | }; 38 | 39 | 40 | /** 41 | * This works like PHP's empty() function. Behaviors: 42 | * - return true if value is falsy 43 | * - return true if the value is the integer zero 44 | * - return true if the value has a length property that is zero 45 | * - return true if the value is an object that does not own any properties 46 | * - return false for everything else 47 | * @param mixed value 48 | * @return {Boolean} 49 | */ 50 | export const empty = lang.empty = (value) => { 51 | if (value === undefined) { 52 | return true; 53 | } 54 | 55 | if (typeof value !== 'object' && isNaN(value)) { 56 | return false; 57 | } 58 | 59 | // falsy value is always empty 60 | if (!value) { 61 | return true; 62 | } 63 | 64 | // number zero is empty 65 | if (typeof value === 'number') { 66 | return value === 0; 67 | } 68 | 69 | // zero-length anything is empty 70 | if (typeof value.length === 'number') { 71 | return !value.length; 72 | } 73 | 74 | // zero-size anything is empty 75 | if (typeof value.size === 'number') { 76 | return !value.size; 77 | } 78 | 79 | // an object that does not own any properties is empty 80 | if (typeof value === 'object') { 81 | return Object.getOwnPropertyNames(value).length === 0; 82 | } 83 | 84 | // default to false 85 | return false; 86 | }; 87 | 88 | -------------------------------------------------------------------------------- /src/models.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import lang from './lang'; 3 | import querystring from './querystring'; 4 | import { resources } from './rest'; 5 | import { each, merge } from 'lodash'; 6 | import { NotImplemented } from './exceptions'; 7 | import sort from './sort'; 8 | 9 | const P = Promise; 10 | const { bool, integer, number, string } = lang.coerce; 11 | 12 | //////////////////////////////////////////////////////////////////////////////// 13 | 14 | const models = {}; 15 | 16 | export default models; 17 | 18 | export const Model = models.Model = class { 19 | 20 | /** 21 | * Return default model attributes 22 | * @param void 23 | * @return {Object} 24 | */ 25 | defaults () { 26 | return { 27 | // the resource that created the model 28 | resource: null, 29 | 30 | // rest.Client instance 31 | client: null, 32 | 33 | // model attributes 34 | attrs: { 35 | related: {}, 36 | }, 37 | }; 38 | } 39 | 40 | /** 41 | * Define attribute mutators 42 | * @param void 43 | * @return {Object} 44 | */ 45 | mutators () { 46 | return { 47 | // attribute: [set-mutator, get-mutator], 48 | id: [integer], 49 | quantity: [integer], 50 | position: [integer], 51 | }; 52 | } 53 | 54 | /** 55 | * @param {Object} attrs - initial model attributes 56 | */ 57 | constructor (options={}) { 58 | this.options = merge(this.defaults(), options); 59 | this.attrs = {}; 60 | this.set(this.options.attrs); 61 | delete this.options.attrs; 62 | } 63 | 64 | /** 65 | * Assign a single attribute or mass-assign multiple attribute. Map 66 | * attribute values through the dictionary returned by mutators(). 67 | * @param ...mixed args 68 | * @return void 69 | */ 70 | set (...args) { 71 | let arity = args.length; 72 | let mutators = this.mutators(); 73 | let defaults = [v=>v]; 74 | 75 | let assign = (value, attr) => { 76 | let [set] = mutators[attr] || defaults; 77 | this.attrs[attr] = set(value); 78 | }; 79 | 80 | if (arity < 1) { 81 | throw new Error('expected at least one argument'); 82 | } 83 | if (arity > 2) { 84 | throw new Error('expected no more than two arguments'); 85 | } 86 | 87 | if (arity === 1) { 88 | each(args[0], assign); 89 | return; 90 | } 91 | 92 | let [attr, value] = args; 93 | assign(value, attr); 94 | } 95 | 96 | /** 97 | * @param void 98 | * @return {Object} 99 | */ 100 | attributes () { 101 | return {...this.attrs}; 102 | } 103 | 104 | /** 105 | * @param void 106 | * @return {Object} 107 | */ 108 | toJSON () { 109 | return this.attributes(); 110 | } 111 | 112 | } 113 | 114 | export const Language = models.Language = class extends Model { 115 | // implement me 116 | } 117 | 118 | export const Product = models.Product = class extends Model { 119 | 120 | /** 121 | * @inheritdoc 122 | */ 123 | mutators () { 124 | return { 125 | ...super.mutators(), 126 | price: [number], 127 | available_for_order: [integer, bool], 128 | }; 129 | } 130 | 131 | /** 132 | * Return a rest.Resource that provides access to the related Manufacturer 133 | * @param void 134 | * @return {rest.resources.Combinations} 135 | */ 136 | manufacturer () { 137 | let related = this.attrs.related; 138 | 139 | return new resources.Manufacturers({ 140 | client: this.options.client, 141 | filter: (manufacturer) => related.manufacturer == manufacturer.attrs.id, 142 | }); 143 | } 144 | 145 | /** 146 | * Return a rest.Resource that provides access to related Images 147 | * @param void 148 | * @return {rest.resources.Images} 149 | */ 150 | images () { 151 | return new resources.Images({ 152 | client: this.options.client, 153 | root: `/images/products/${this.attrs.id}`, 154 | }); 155 | } 156 | 157 | /** 158 | * Return a rest.Resource that provides access to related Combinations 159 | * @param void 160 | * @return {rest.resources.Combinations} 161 | */ 162 | combinations () { 163 | let related = this.attrs.related; 164 | 165 | return new resources.Combinations({ 166 | client: this.options.client, 167 | filter: (combo) => related.combinations.indexOf(combo.attrs.id) > -1, 168 | }); 169 | } 170 | } 171 | 172 | export const Image = models.Image = class extends Model { 173 | 174 | /** 175 | * Return a Buffer that contains the raw image 176 | * @async Promise 177 | * @param void 178 | * @return {Buffer} 179 | */ 180 | load () { 181 | if (this.loading) { 182 | return P.resolve(this.loading); 183 | } 184 | 185 | let {client} = this.options; 186 | let attrs = this.attrs; 187 | 188 | this.loading = client.get(attrs.src) 189 | 190 | .then((response) => { 191 | response = response.clone(); 192 | return P.all([P.resolve(response), response.clone().buffer()]); 193 | }) 194 | 195 | .then((result) => { 196 | let [response, buffer] = result; 197 | // TODO inspect the Content-Type response header and set "type" attribute 198 | return buffer; 199 | }) 200 | 201 | return this.loading; 202 | } 203 | 204 | } 205 | 206 | export const Combination = models.Combination = class extends Model { 207 | 208 | /** 209 | * Define property mutators 210 | * @param void 211 | * @return {Object} 212 | */ 213 | mutators () { 214 | return { 215 | // property: [set-mutator, get-mutator], 216 | ...super.mutators(), 217 | id_product: [integer], 218 | quantity: [integer], 219 | price: [number], 220 | ecotax: [number], 221 | weight: [number], 222 | unit_price_impact: [number], 223 | minimal_quantity: [number], 224 | }; 225 | } 226 | 227 | /** 228 | * Return a rest.Resource that provides access to the parent Product 229 | * models. 230 | * @param void 231 | * @return {rest.resources.Products} 232 | */ 233 | product () { 234 | return new resources.Product({ 235 | client: this.options.client, 236 | filter: (product) => this.attrs.related.product == product.attrs.id, 237 | }); 238 | } 239 | 240 | /** 241 | * Return a rest.Resource that provides access to related ProductOptionValue 242 | * models. 243 | * @param void 244 | * @return {rest.resources.ProductOptionvalues} 245 | */ 246 | product_option_values () { 247 | return new resources.ProductOptionValues({ 248 | client: this.options.client, 249 | filter: (pov) => { 250 | return this.attrs.related.product_option_values == pov.attrs.id; 251 | }, 252 | }); 253 | } 254 | 255 | /** 256 | * Return a rest.Resource that provides access to related StockAvailable 257 | * models. 258 | * @param void 259 | * @return {rest.resources.ProductOptionvalues} 260 | */ 261 | stock_availables () { 262 | return new resources.StockAvailables({ 263 | client: this.options.client, 264 | filter: (stock) => { 265 | return this.attrs.id == stock.attrs.id_product_attribute; 266 | }, 267 | }); 268 | } 269 | } 270 | 271 | export const StockAvailable = models.StockAvailable = class extends Model { 272 | 273 | /** 274 | * @inheritdoc 275 | */ 276 | mutators () { 277 | return { 278 | ...super.mutators(), 279 | id_product: [integer], 280 | id_product_attribute: [integer], 281 | id_shop: [integer], 282 | id_shop_group: [integer], 283 | quantity: [integer], 284 | depends_on_stock: [integer], 285 | out_of_stock: [integer], 286 | }; 287 | } 288 | } 289 | 290 | export const Manufacturer = models.Manufacturer = class extends Model { 291 | // implement me 292 | } 293 | 294 | export const ProductOptionValue = models.ProductOptionValue = class extends Model { 295 | // implement me 296 | } 297 | 298 | -------------------------------------------------------------------------------- /src/querystring.js: -------------------------------------------------------------------------------- 1 | import { map } from 'lodash'; 2 | 3 | //////////////////////////////////////////////////////////////////////////////// 4 | const querystring = {}; 5 | 6 | export default querystring; 7 | 8 | /** 9 | * Stringify a query per RFC 3986 10 | * @usage 11 | * 12 | * // serialize a dictionary 13 | * stringify({foo: 1, bar: 2}) 14 | * >>> foo=1&bar=2 15 | * 16 | * // serialize an array of key-value tuples 17 | * stringify([['foo', 1], ['bar', 2]]) 18 | * >>> foo=1&bar=2 19 | * 20 | * @param {Object|Array} query - a dictionary or an array of key-value tuples 21 | * @return {String} 22 | */ 23 | export const stringify = querystring.stringify = (query) => { 24 | let encode = encodeURIComponent; 25 | let serialize = (k, v) => `${encode(k)}=${encode(v)}`; 26 | 27 | if (Array.isArray(query)) { 28 | return map(query, (tuple) => serialize(...tuple)).join('&'); 29 | } 30 | 31 | return map(query, (v, k) => serialize(k, v)).join('&'); 32 | }; 33 | -------------------------------------------------------------------------------- /src/rest.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import querystring from './querystring'; 3 | import { each, merge } from 'lodash'; 4 | import { NotImplemented, InvalidArgument, UnexpectedValue } from './exceptions'; 5 | import { parse } from './xml'; 6 | import { empty, tuples, coerce } from './lang'; 7 | import models from './models'; 8 | import sort from './sort'; 9 | import string from './string'; 10 | import fetch from 'node-fetch'; 11 | 12 | const { integer } = coerce; 13 | const P = Promise; 14 | const noop = () => {}; 15 | const dummylogger = {log: noop, info: noop}; 16 | 17 | //////////////////////////////////////////////////////////////////////////////// 18 | 19 | /** 20 | * HTTP client 21 | */ 22 | export const Client = class { 23 | 24 | /** 25 | * Return instance configuration defaults 26 | * @param void 27 | * @return {Object} 28 | */ 29 | defaults () { 30 | let location = global.location || { 31 | protocol: 'https:', 32 | host: 'localhost', 33 | }; 34 | 35 | return { 36 | language: 'en', 37 | 38 | // these must match your PrestaShop backend languages 39 | languages: { 40 | // ISO code => PrestaShop language id 41 | 'en': 1, 42 | }, 43 | 44 | // PrestaShop web service parameters 45 | webservice: { 46 | key: 'your-prestashop-key', 47 | scheme: 'https', 48 | host: 'your-prestashop-host', 49 | root: '/api', 50 | }, 51 | 52 | // logger 53 | logger: dummylogger, 54 | 55 | // Fetch-related options 56 | fetch: { 57 | 58 | // the actual fetch function; facilitates testing 59 | algo: fetch, 60 | }, 61 | }; 62 | } 63 | 64 | /** 65 | * @param {Object} options 66 | */ 67 | constructor (options={}) { 68 | this.options = merge(this.defaults(), options); 69 | this.fetch = this.options.fetch.algo; 70 | this.logger = this.options.logger; 71 | this.funnel = {}; 72 | } 73 | 74 | /** 75 | * Return a Prestashop API language id 76 | * @property 77 | * @type {Number} 78 | */ 79 | get language () { 80 | let {languages, language: isocode} = this.options; 81 | return languages[isocode]; 82 | } 83 | 84 | /** 85 | * Set the current client language ISO code 86 | * @param {String} iso 87 | * @return void 88 | */ 89 | setLanguageIso (iso) { 90 | let {languages} = this.options; 91 | 92 | if (!languages.hasOwnProperty(iso)) { 93 | throw new Error(`language "${iso}" is not configured`); 94 | } 95 | 96 | this.options.language = iso; 97 | } 98 | 99 | /** 100 | * Return the current client language ISO code 101 | * @param void 102 | * @return {String} 103 | */ 104 | getLanguageIso () { 105 | return this.options.language; 106 | } 107 | 108 | /** 109 | * Send a GET request 110 | * @async Promise 111 | * @param {String} url 112 | * @param {Object} query 113 | * @return {Response} 114 | */ 115 | get (uri, options={}) { 116 | let url = this.url(uri, options.query); 117 | let key = `${this.options.language}:GET:${url}`; 118 | let funnel = this.funnel; 119 | 120 | // concurrent requests on the same url converge on a single promise 121 | if (funnel[key]) { 122 | return funnel[key]; 123 | } 124 | 125 | let fopts = this.createFetchOptions({...options.fetch, method: 'GET'}); 126 | 127 | funnel[key] = this.fetch(url, fopts) 128 | 129 | .then((response) => { 130 | delete funnel[key]; 131 | this.validateResponse(response); 132 | return response; 133 | }) 134 | 135 | .catch((e) => { 136 | delete funnel[key]; 137 | throw e; 138 | }) 139 | 140 | return funnel[key]; 141 | } 142 | 143 | /** 144 | * Return a fully qualified API url 145 | * @param {String} uri 146 | * @param {String} 147 | */ 148 | url (uri, query={}) { 149 | let qs = ''; 150 | 151 | if (!empty(query)) { 152 | query = tuples(query).sort(); 153 | qs = '?' + querystring.stringify(query); 154 | } 155 | 156 | if (uri.indexOf('http') === 0) { 157 | return uri + qs; 158 | } 159 | 160 | let {webservice} = this.options; 161 | let fullpath = path.join(webservice.root, uri); 162 | 163 | return `${webservice.scheme}://${webservice.host}${fullpath}${qs}`; 164 | } 165 | 166 | /** 167 | * Return node-fetch request options 168 | * @param {Object} augments 169 | * @return {Object} 170 | */ 171 | createFetchOptions (augments={}) { 172 | return { 173 | ...this.options.fetch.defaults, 174 | headers: this.createHeaders(), 175 | ...augments, 176 | }; 177 | } 178 | 179 | /** 180 | * Return a dictionary containing request headers 181 | * @param {Object} augments 182 | * @return {Object} 183 | */ 184 | createHeaders (augments={}) { 185 | let {key} = {...this.options.webservice}; 186 | let Authorization = this.createAuthorizationHeader(key); 187 | 188 | return {...augments, Authorization}; 189 | } 190 | 191 | /** 192 | * @param {Response} response 193 | * @return void 194 | * @throws Error 195 | */ 196 | validateResponse (response) { 197 | if (!response.ok) { 198 | throw new UnexpectedValue('got non-2XX HTTP response'); 199 | } 200 | } 201 | 202 | /** 203 | * Return an Authorization header value given a web service key 204 | * @param {String} key 205 | * @return {String} 206 | */ 207 | createAuthorizationHeader (key) { 208 | let username = key; 209 | let password = ''; 210 | let precursor = `${username}:${password}`; 211 | let credentials = Buffer.from(precursor).toString('base64'); 212 | 213 | return `Basic ${credentials}`; 214 | } 215 | 216 | /** 217 | * @param {String} api - snake case form of a resource class name 218 | * @param {Object} options 219 | * @return {rest.Resource} 220 | */ 221 | resource (api, options={}) { 222 | let classname = string.studly(api); 223 | let constructor = resources[classname]; 224 | 225 | if (!constructor) { 226 | throw new InvalidArgument(`invalid root resource: "${api}"`); 227 | } 228 | 229 | return new constructor({ 230 | ...options, 231 | client: this, 232 | logger: this.logger, 233 | }); 234 | } 235 | 236 | } 237 | 238 | //////////////////////////////////////////////////////////////////////////////// 239 | export const resources = {}; 240 | 241 | /** 242 | * Resource is an HTTP-aware context that uses a Client to fetch XML payloads 243 | * from the PrestaShop API. It converts XML payloads into Model instances. 244 | */ 245 | export const Resource = resources.Resource = class { 246 | 247 | /** 248 | * Return the canonical constructor name. This is a workaround to a side 249 | * effect of Babel transpilation, which is that Babel mangles class names. 250 | * @property 251 | * @type {String} 252 | */ 253 | static get name () { 254 | return 'Resource'; 255 | } 256 | 257 | /** 258 | * Return instance configuration defaults 259 | * @param void 260 | * @return {Object} 261 | */ 262 | defaults () { 263 | let classname = this.constructor.name; 264 | let api = string.snake(classname); 265 | let nodetype = api.slice(0, -1); 266 | let root = `/${api}`; 267 | let modelname = classname.slice(0, -1); 268 | 269 | return { 270 | client: null, 271 | logger: dummylogger, 272 | model: models[modelname], 273 | root: root, 274 | api: api, 275 | nodetype: nodetype, 276 | 277 | // model list filter function 278 | filter: null, 279 | 280 | // model list sort function 281 | sort: null, 282 | }; 283 | } 284 | 285 | /** 286 | * @param {Object} options 287 | */ 288 | constructor (options={}) { 289 | this.options = {...this.defaults(), ...options}; 290 | this.client = this.options.client; 291 | this.logger = this.options.logger; 292 | } 293 | 294 | /** 295 | * Return a PrestaShop language id 296 | * @property 297 | * @type {Number} 298 | */ 299 | get language () { 300 | return this.client.language; 301 | } 302 | 303 | /** 304 | * Resolve an array of Model instances 305 | * @async Promise 306 | * @param void 307 | * @return {Array} 308 | */ 309 | list () { 310 | return this.client.get(this.options.root) 311 | 312 | .then((response) => response.clone().text()) 313 | .then((xml) => this.parseModelIds(xml)) 314 | .then((ids) => this.createModels(ids)) 315 | .then((models) => { 316 | let {sort, filter} = this.options; 317 | 318 | if (filter) { 319 | models = models.filter(filter); 320 | } 321 | if (sort) { 322 | models = models.sort(sort); 323 | } 324 | 325 | return models; 326 | }) 327 | } 328 | 329 | /** 330 | * Return the first member of a list() result 331 | * @param void 332 | * @return {models.Model} 333 | */ 334 | first () { 335 | return this.list().then(models => models.shift() || null); 336 | } 337 | 338 | /** 339 | * Resolve a single Model instance, or null if there was an error of any kind 340 | * @async Promise 341 | * @param {mixed} id 342 | * @return {Model|null} 343 | */ 344 | get (id) { 345 | let uri = `${this.options.root}/${id}`; 346 | let promise; 347 | 348 | try { 349 | promise = this.client.get(uri) 350 | } 351 | catch (e) { 352 | this.logger.log(`failed to acquire model properties on request path ${uri}`); 353 | this.logger.log(e.message); 354 | this.logger.log(e.stack); 355 | 356 | // FIXME 357 | return P.resolve(this.createModel()); 358 | } 359 | 360 | return promise.then((response) => response.clone().text()) 361 | .then((xml) => this.parseModelAttributes(xml)) 362 | .then((attrs) => this.createModel(attrs)) 363 | } 364 | 365 | /** 366 | * Map a list of model ids to model instances. 367 | * @async Promise 368 | * @param {Array} ids 369 | * @return {Array} 370 | */ 371 | createModels (ids) { 372 | return P.all(ids.map((id) => this.get(id))); 373 | } 374 | 375 | /** 376 | * Given an object containing model properties, return a Model instance. 377 | * @param {Object} attrs 378 | * @return {Model} 379 | */ 380 | createModel (attrs={}) { 381 | let {model: constructor} = this.options; 382 | 383 | return new constructor({ 384 | client: this.client, 385 | resource: this, 386 | attrs: attrs, 387 | }); 388 | } 389 | 390 | /** 391 | * Given the API response payload for a list of objects, return a list of 392 | * model ids. 393 | * @async Promise 394 | * @param {String} xml 395 | * @return {Array} 396 | */ 397 | parseModelIds (xml) { 398 | let {api, nodetype} = this.options; 399 | return parse.model.ids(xml, api, nodetype); 400 | } 401 | 402 | /** 403 | * Given the API response payload for a single domain object, return a plain 404 | * object that contains model properties. 405 | * @param {String} xml 406 | * @return {Object} 407 | */ 408 | parseModelAttributes (xml) { 409 | let nodetype = this.options.nodetype; 410 | let ns = parse[nodetype]; 411 | 412 | if (!ns) { 413 | throw new UnexpectedValue(`parser namespace not found on node type ${nodetype}`); 414 | } 415 | if (!ns.attributes) { 416 | throw new UnexpectedValue(`model properties parser not found on node type ${nodetype}`); 417 | } 418 | 419 | return parse[nodetype].attributes(xml, this.language); 420 | } 421 | 422 | } 423 | 424 | resources.Products = class extends Resource { 425 | 426 | /** 427 | * inheritdoc 428 | */ 429 | static get name () { 430 | return 'Products'; 431 | } 432 | } 433 | 434 | resources.Images = class extends Resource { 435 | 436 | static get name () { 437 | return 'Images'; 438 | } 439 | 440 | /** 441 | * @inheritdoc 442 | */ 443 | list () { 444 | return this.client.get(this.options.root) 445 | .then((response) => response.clone().text()) 446 | .then((xml) => this.parseImageAttributes(xml)) 447 | .then((attrsets) => attrsets.map((attrs) => this.createModel(attrs))) 448 | } 449 | 450 | /** 451 | * Return a list of urls given an XML payload 452 | * @async Promise 453 | * @param {String} xml 454 | * @return {Array} 455 | */ 456 | parseImageAttributes (xml) { 457 | return parse.image.attributes(xml); 458 | } 459 | } 460 | 461 | 462 | resources.Manufacturers = class extends Resource { 463 | 464 | /** 465 | * @inheritdoc 466 | */ 467 | static get name () { 468 | return 'Manufacturers'; 469 | } 470 | } 471 | 472 | resources.Combinations = class extends Resource { 473 | 474 | /** 475 | * @inheritdoc 476 | */ 477 | static get name () { 478 | return 'Combinations'; 479 | } 480 | } 481 | 482 | resources.StockAvailables = class extends Resource { 483 | 484 | /** 485 | * @inheritdoc 486 | */ 487 | static get name () { 488 | return 'StockAvailables'; 489 | } 490 | } 491 | 492 | resources.ProductOptionValues = class extends Resource { 493 | 494 | /** 495 | * inheritdoc 496 | */ 497 | static get name () { 498 | return 'ProductOptionValues'; 499 | } 500 | 501 | /** 502 | * @inheritdoc 503 | */ 504 | defaults () { 505 | return { 506 | ...super.defaults(), 507 | sort: sort.ascending(model => model.position), 508 | }; 509 | } 510 | } 511 | 512 | 513 | export default { Client, resources }; 514 | -------------------------------------------------------------------------------- /src/sort.js: -------------------------------------------------------------------------------- 1 | const sort = {}; 2 | 3 | /** 4 | * Return a sort comparator given a function that returns a sortable value. 5 | * @param {Function} resolve 6 | * @return {Function} 7 | */ 8 | export const ascending = sort.ascending = (resolve) => { 9 | return (a, b) => resolve(a) - resolve(b); 10 | }; 11 | 12 | /** 13 | * Return a sort comparator given a function that returns a sortable value. 14 | * @param {Function} resolve 15 | * @return {Function} 16 | */ 17 | export const descending = sort.descending = (resolve) => { 18 | return (a, b) => resolve(b) - resolve(a); 19 | }; 20 | 21 | export default sort; 22 | -------------------------------------------------------------------------------- /src/string.js: -------------------------------------------------------------------------------- 1 | import inflector from 'i'; 2 | const inflect = inflector(); 3 | 4 | const string = {}; 5 | 6 | export default string; 7 | 8 | string.snake = (...args) => inflect.underscore(...args); 9 | string.studly = (...args) => inflect.camelize(...args); 10 | -------------------------------------------------------------------------------- /src/xml.js: -------------------------------------------------------------------------------- 1 | import xml2js from 'xml2js'; 2 | import { coerce } from './lang'; 3 | const { bool, number, integer, string } = coerce; 4 | const P = Promise; 5 | 6 | //////////////////////////////////////////////////////////////////////////////// 7 | const xml = {}; 8 | 9 | export default xml; 10 | 11 | /** 12 | * Return a plain object representation of the supplied XML payload 13 | * @async Promise 14 | * @param {String|Buffer} xml 15 | * @return {Object} 16 | */ 17 | export const parse = xml.parse = (xml) => { 18 | return new P((resolve, reject) => { 19 | xml2js.parseString(String(xml), (err, result) => { 20 | err ? reject(err) : resolve(result); 21 | }); 22 | }) 23 | }; 24 | 25 | /** 26 | * Extract the text value from a node 27 | * @param mixed input 28 | * @return {String} 29 | */ 30 | const text = (input) => { 31 | let raw = Array.isArray(input) ? input[0] : input; 32 | 33 | if (typeof raw == 'object') { 34 | raw = raw._ || ''; 35 | } 36 | 37 | return String(raw).trim(); 38 | }; 39 | 40 | /** 41 | * Extract a node attribute value 42 | * @param {mixed} input 43 | * @return {String} 44 | */ 45 | const attr = (input, name) => { 46 | let raw = input; 47 | 48 | if (Array.isArray(raw)) { 49 | raw = raw[0]; 50 | } 51 | 52 | if (raw.$ && raw.$[name] !== undefined) { 53 | raw = raw.$[name]; 54 | } 55 | 56 | return String(raw).trim(); 57 | }; 58 | 59 | //////////////////////////////////////////////////////////////////////////////// 60 | 61 | parse.model = {}; 62 | 63 | /** 64 | * Parse the supplied XML payload and return an array of model ids. 65 | * @async Promise 66 | * @param {String|Buffer} xml 67 | * @param {String} api 68 | * @param {String} nodetype 69 | * @return {Array} 70 | */ 71 | parse.model.ids = (xml, api, nodetype) => { 72 | return parse(xml) 73 | 74 | .then((obj) => { 75 | let list = obj.prestashop[api][0][nodetype]; 76 | return list.map((obj) => integer(attr(obj, 'id'))); 77 | }); 78 | }; 79 | 80 | //////////////////////////////////////////////////////////////////////////////// 81 | 82 | parse.product = {}; 83 | 84 | /** 85 | * @async Promise 86 | * @param {String} xml 87 | * @return {Object} 88 | */ 89 | parse.product.attributes = (xml, language=1) => { 90 | return parse(xml) 91 | 92 | .then((obj) => { 93 | let base = obj.prestashop.product[0]; 94 | let assocs = base.associations[0] || {}; 95 | let lang = (obj) => attr(obj, 'id') == language; 96 | 97 | let names = base.name[0].language; 98 | let descs = base.description[0].language; 99 | let shortdescs = base.description_short[0].language; 100 | let combos = assocs.combinations[0].combination || []; 101 | let images = assocs.images[0].image || [];; 102 | 103 | return { 104 | 'id': text(base.id), 105 | 'name': text(names.filter(lang).pop()), 106 | 'description': text(descs.filter(lang).pop()), 107 | 'description_short': text(shortdescs.filter(lang).pop()), 108 | 'price': text(base.price), 109 | 'available_for_order': text(base.available_for_order), 110 | 'manufacturer_name': text(base.manufacturer_name), 111 | 'related': { 112 | 'manufacturer': integer(text(base.id_manufacturer)), 113 | 'combinations': combos.map(combo => integer(text(combo.id))), 114 | 'images': images.map(image => integer(text(image.id))), 115 | }, 116 | }; 117 | }); 118 | }; 119 | 120 | //////////////////////////////////////////////////////////////////////////////// 121 | 122 | parse.combination = {}; 123 | 124 | /** 125 | * @async Promise 126 | * @param {String} xml 127 | * @return {Object} 128 | */ 129 | parse.combination.attributes = (xml) => { 130 | return parse(xml) 131 | 132 | .then((obj) => { 133 | let base = obj.prestashop.combination[0]; 134 | let assocs = base.associations[0] || {}; 135 | let povs = assocs.product_option_values || []; 136 | 137 | return { 138 | 'id': text(base.id), 139 | 'id_product': text(base.id_product), 140 | 'location': text(base.location), 141 | 'ean13': text(base.ean13), 142 | 'upc': text(base.upc), 143 | 'quantity': text(base.quantity), 144 | 'reference': text(base.reference), 145 | 'supplier_reference': text(base.supplier_reference), 146 | 'wholesale_price': text(base.wholesale_price), 147 | 'price': text(base.price), 148 | 'ecotax': text(base.ecotax), 149 | 'weight': text(base.weight), 150 | 'unit_price_impact': text(base.unit_price_impact), 151 | 'minimal_quantity': text(base.minimal_quantity), 152 | 'default_on': text(base.default_on), 153 | 'available_date': text(base.available_date), 154 | 'related': { 155 | 'product': integer(text(base.id_product)), 156 | 'product_option_values': integer(text(povs[0].product_option_value[0].id)), 157 | }, 158 | }; 159 | }) 160 | } 161 | 162 | //////////////////////////////////////////////////////////////////////////////// 163 | 164 | parse.image = {}; 165 | 166 | /** 167 | * @async Promise 168 | * @param {String} xml 169 | * @return {Object} 170 | */ 171 | parse.image.attributes = (xml) => { 172 | return parse(xml) 173 | 174 | .then((obj) => { 175 | let decs = obj.prestashop.image[0].declination; 176 | 177 | return decs.map((dec) => { 178 | return { 179 | 'id': dec.$.id, 180 | 'src': dec.$['xlink:href'], 181 | }; 182 | }); 183 | }) 184 | }; 185 | 186 | //////////////////////////////////////////////////////////////////////////////// 187 | 188 | parse.manufacturer = {}; 189 | 190 | parse.manufacturer.attributes = (xml, language=1) => { 191 | return parse(xml) 192 | 193 | .then((obj) => { 194 | let base = obj.prestashop.manufacturer[0]; 195 | let lang = (obj) => attr(obj, 'id') == language; 196 | let descs = base.description[0].language; 197 | 198 | return { 199 | 'id': text(base.id), 200 | 'active': text(base.active), 201 | 'name': text(base.name), 202 | 'description': text(descs.filter(lang).pop()), 203 | }; 204 | }) 205 | }; 206 | 207 | //////////////////////////////////////////////////////////////////////////////// 208 | 209 | parse.stock_available = {}; 210 | 211 | parse.stock_available.attributes = (xml, language=1) => { 212 | return parse(xml) 213 | 214 | .then((obj) => { 215 | let base = obj.prestashop.stock_available[0]; 216 | 217 | return { 218 | 'id': text(base.id), 219 | 'id_product': text(base.id_product), 220 | 'id_product_attribute': text(base.id_product_attribute), 221 | 'id_shop': text(base.id_shop), 222 | 'id_shop_group': text(base.id_shop_group), 223 | 'quantity': text(base.quantity), 224 | 'depends_on_stock': text(base.depends_on_stock), 225 | 'out_of_stock': text(base.out_of_stock), 226 | }; 227 | }) 228 | }; 229 | 230 | //////////////////////////////////////////////////////////////////////////////// 231 | 232 | parse.product_option_value = {}; 233 | 234 | parse.product_option_value.attributes = (xml, language=1) => { 235 | return parse(xml) 236 | 237 | .then((obj) => { 238 | let base = obj.prestashop.product_option_value[0]; 239 | let names = base.name[0].language; 240 | let lang = (obj) => attr(obj, 'id') == language; 241 | 242 | return { 243 | 'id': text(base.id), 244 | 'id_attribute_group': text(base.id_attribute_group), 245 | 'color': text(base.color), 246 | 'position': text(base.position), 247 | 'name': text(names.filter(lang).pop()), 248 | }; 249 | }) 250 | }; 251 | 252 | -------------------------------------------------------------------------------- /tests/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import assert from 'assert'; 6 | 7 | global.assert = assert; 8 | global.expect = expect; 9 | global.sinon = sinon; 10 | global.match = sinon.match; 11 | global.spy = sinon.spy; 12 | global.stub = sinon.stub; 13 | global.mock = sinon.mock; 14 | 15 | /** 16 | * @param {String} abspath 17 | * @return {String} 18 | */ 19 | const readfile = (abspath) => { 20 | return String(fs.readFileSync(abspath)).trim(); 21 | }; 22 | 23 | const FIXTURE_PATH = path.normalize(path.join(__dirname, 'fixtures')); 24 | 25 | /** 26 | * @param {String} relpath 27 | * @return {String} 28 | */ 29 | global.fixture = (relpath) => { 30 | return readfile(path.join(FIXTURE_PATH, relpath)); 31 | }; 32 | 33 | -------------------------------------------------------------------------------- /tests/fixtures/combination-47.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/fixtures/manufacturer-1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 | 66 | 67 | 68 |
69 |
70 |
71 |
72 |
73 | -------------------------------------------------------------------------------- /tests/fixtures/manufacturers.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/fixtures/product-10.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |

63 | ]]>
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 |
195 |
196 | 197 | -------------------------------------------------------------------------------- /tests/fixtures/product-8-images.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/product-8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 |

Silver gelatin print measuring
27.9 × 35.6 cm, unframed.
Printed under the direct supervision of the artist.
One of a signed, limited edition of 10.

]]> 195 |
196 | 197 | 198 | 199 |
200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 |
421 |
422 | -------------------------------------------------------------------------------- /tests/fixtures/product-9.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 |

Silver gelatin print measuring
27.9 × 35.6 cm, unframed.
Printed under the direct supervision of the artist.
One of a signed, limited edition of 10.

]]> 193 |
194 | 195 | 196 | 197 |
198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 |
419 |
420 | -------------------------------------------------------------------------------- /tests/fixtures/product-option-values-25.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/fixtures/products.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/stock-available-80.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/fixtures/stock-availables.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tests/fixtures/valid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/functional/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tests/unit/exceptions.spec.js: -------------------------------------------------------------------------------- 1 | import {HttpException} from '../../src/exceptions'; 2 | 3 | describe('exceptions', () => { 4 | describe('HttpException', () => { 5 | describe('#constructor()', () => { 6 | it('accepts a status argument', () => { 7 | let invokation = () => new HttpException('not found', null, null, 404); 8 | expect(invokation).not.to.throw(); 9 | }); 10 | 11 | it('assigns the #status property', () => { 12 | let e = new HttpException('not found', null, null, 404); 13 | expect(e.status).to.equal(404); 14 | }); 15 | }); 16 | }); 17 | }); 18 | 19 | -------------------------------------------------------------------------------- /tests/unit/lang.spec.js: -------------------------------------------------------------------------------- 1 | import { empty, tuples } from '../../src/lang'; 2 | import assert from 'assert'; 3 | 4 | describe('lang', () => { 5 | 6 | describe('.empty()', () => { 7 | 8 | const TestClass = function() {}; 9 | TestClass.prototype = {constructor: TestClass, foo: 1}; 10 | 11 | it('false is empty', () => expect(empty(false)).to.equal(true)); 12 | it('true is not empty', () => expect(empty(true)).to.equal(false)); 13 | 14 | it('null is empty', () => expect(empty(null)).to.equal(true)); 15 | it('undefined is empty', () => expect(empty(undefined)).to.equal(true)); 16 | 17 | it('zero-length string is empty', () => expect(empty('')).to.equal(true)); 18 | it('non-zero length string is not empty', () => expect(empty(' ')).to.equal(false)); 19 | 20 | it('zero-length array is empty', () => expect(empty([])).to.equal(true)); 21 | it('non-zero length array is not empty', () => expect(empty([0])).to.equal(false)); 22 | 23 | it('integer zero is empty', () => expect(empty(0)).to.equal(true)); 24 | it('number other than integer zero is not empty', () => expect(empty(-1)).to.equal(false)); 25 | it('NaN is not empty', () => expect(empty(NaN)).to.equal(false)); 26 | 27 | it('Map with no keys is empty', () => expect(empty(new Map())).to.equal(true)); 28 | it('Set with no values is empty', () => expect(empty(new Set())).to.equal(true)); 29 | 30 | it('object that owns no properties is empty', () => { 31 | assert(Object.getOwnPropertyNames({}).length === 0); 32 | expect(empty({})).to.equal(true); 33 | }); 34 | 35 | it('object that owns at least one property is not empty', () => { 36 | let TestClass = function() {}; 37 | 38 | TestClass.prototype = {constructor: TestClass}; 39 | 40 | let obj = new TestClass(); 41 | obj.bar = 2; 42 | expect(empty(obj)).to.equal(false); 43 | }); 44 | 45 | it('Map with at least one key is not empty', () => { 46 | let map = new Map([['foo', 1]]); 47 | expect(empty(map)).to.equal(false); 48 | }); 49 | 50 | it('Set with at least one value is not empty', () => { 51 | let set = new Set(['foo']); 52 | expect(empty(set)).to.equal(false); 53 | }); 54 | }); 55 | 56 | 57 | describe('.tuples()', () => { 58 | it('converts an Object to an Array of two-element tuples', () => { 59 | let input = {foo: 1, bar: 2}; 60 | let output = tuples(input); 61 | 62 | expect(output).to.be.an.instanceof(Array); 63 | expect(output.length).to.equal(2); 64 | 65 | expect(output[0]).to.be.an.instanceof(Array); 66 | expect(output[1]).to.be.an.instanceof(Array); 67 | 68 | expect(output[0].length).to.equal(2); 69 | expect(output[1].length).to.equal(2); 70 | expect(output[0][0]).to.equal('foo'); 71 | expect(output[0][1]).to.equal(1); 72 | expect(output[1][0]).to.equal('bar'); 73 | expect(output[1][1]).to.equal(2); 74 | }); 75 | 76 | it('converts a Map to an Array of two-element tuples', () => { 77 | let input = new Map([['foo', 1], ['bar', 2]]); 78 | let output = tuples(input); 79 | 80 | expect(output).to.be.an.instanceof(Array); 81 | expect(output.length).to.equal(2); 82 | 83 | expect(output[0]).to.be.an.instanceof(Array); 84 | expect(output[1]).to.be.an.instanceof(Array); 85 | 86 | expect(output[0].length).to.equal(2); 87 | expect(output[1].length).to.equal(2); 88 | expect(output[0][0]).to.equal('foo'); 89 | expect(output[0][1]).to.equal(1); 90 | expect(output[1][0]).to.equal('bar'); 91 | expect(output[1][1]).to.equal(2); 92 | }); 93 | 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/unit/models/Model.spec.js: -------------------------------------------------------------------------------- 1 | import models from '../../../src/models'; 2 | import rest from '../../../src/rest'; 3 | 4 | const { Model } = models; 5 | 6 | describe('models.Model', () => { 7 | 8 | describe('#constructor()', () => { 9 | it('mass-assigns supplied properties', () => { 10 | let model = new Model({attrs: {foo: 2}}); 11 | expect(model.attrs.foo).to.equal(2); 12 | }); 13 | }); 14 | 15 | describe('#set()', () => { 16 | it('mass-assigns supplied properties at arity 1', () => { 17 | let model = new Model(); 18 | model.set({foo: 2, bar: 3}); 19 | expect(model.attrs.foo).to.equal(2); 20 | expect(model.attrs.bar).to.equal(3); 21 | }); 22 | 23 | it('assigns a single property at arity 2', () => { 24 | let model = new Model(); 25 | model.set('foo', 2); 26 | expect(model.attrs.foo).to.equal(2); 27 | }) 28 | 29 | it('coerces property values via #mutators()', () => { 30 | let model = new Model(); 31 | model.mutators = stub().returns({foo: [x=>'scooby-doo']}); 32 | model.set('foo', 2); 33 | 34 | expect(model.attrs.foo).to.equal('scooby-doo'); 35 | expect(model.mutators.calledOnce).to.be.ok; 36 | }); 37 | }); 38 | 39 | describe('#mutators()', () => { 40 | it('returns an object', () => { 41 | let model = new Model(); 42 | let mutators = model.mutators(); 43 | 44 | expect(mutators).to.be.ok; 45 | expect(mutators).to.be.an.instanceof(Object); 46 | expect(mutators).not.to.be.an.instanceof(Array) 47 | }); 48 | }); 49 | 50 | }); 51 | -------------------------------------------------------------------------------- /tests/unit/models/Product.spec.js: -------------------------------------------------------------------------------- 1 | import models from '../../../src/models'; 2 | import { resources } from '../../../src/rest'; 3 | const { Product } = models; 4 | 5 | describe('models.Product', () => { 6 | 7 | describe('#images()', () => { 8 | it('returns a rest.resources.Images instance', () => { 9 | let model = new Product({props: {id: 8}}); 10 | expect(model.images()).to.be.an.instanceof(resources.Images); 11 | }); 12 | }) 13 | 14 | describe('#combinations()', () => { 15 | it('returns a rest.resources.Combinations instance', () => { 16 | let model = new Product({props: {id: 8}}); 17 | expect(model.combinations()).to.be.an.instanceof(resources.Combinations); 18 | }); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /tests/unit/querystring.spec.js: -------------------------------------------------------------------------------- 1 | import querystring from '../../src/querystring'; 2 | 3 | describe('querystring', () => { 4 | describe('.stringify()', () => { 5 | it('converts an Object to a query string', () => { 6 | let input = {foo: 'one', bar: 2}; 7 | let expected = 'foo=one&bar=2'; 8 | let actual = querystring.stringify(input); 9 | 10 | expect(actual).to.equal(expected); 11 | }); 12 | 13 | it('converts a list of tuples to a query string', () => { 14 | let input = [['foo', 'one'], ['bar', 2]]; 15 | let expected = 'foo=one&bar=2'; 16 | let actual = querystring.stringify(input); 17 | 18 | expect(actual).to.equal(expected); 19 | }); 20 | 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/unit/rest/Client.spec.js: -------------------------------------------------------------------------------- 1 | import { Client } from '../../../src/rest'; 2 | const P = Promise; 3 | const error = new Error(); 4 | const pass = () => P.resolve(); 5 | const fail = () => P.reject(); 6 | 7 | describe('rest.Client', () => { 8 | 9 | describe('#language', () => { 10 | it('is a language id', () => { 11 | let client = new Client({ 12 | language: 'es', 13 | languages: { 14 | 'en': 1, 15 | 'es': 2, 16 | }, 17 | }); 18 | 19 | expect(client.language).to.equal(2); 20 | }); 21 | }); 22 | 23 | describe('#url()', () => { 24 | it('derives a web service url', () => { 25 | let client = new Client({ 26 | webservice: {scheme: 'https', host: 'api.local:3000', root: '/api'}, 27 | }); 28 | 29 | let expected = 'https://api.local:3000/api/foo/bar'; 30 | let actual = client.url('/foo/bar'); 31 | 32 | expect(actual).to.equal(expected); 33 | }); 34 | 35 | it('derives a web service url with query parameters', () => { 36 | let client = new Client({ 37 | webservice: {scheme: 'https', host: 'api.local:3000', root: '/api'}, 38 | }); 39 | 40 | let expected = 'https://api.local:3000/api/foo/bar?a=1&b=2'; 41 | let actual = client.url('/foo/bar', {a: 1, b: 2}); 42 | 43 | expect(actual).to.equal(expected); 44 | }); 45 | 46 | it('query string is deterministic', () => { 47 | let client = new Client({ 48 | webservice: {scheme: 'https', host: 'api.local:3000', root: '/api'}, 49 | }); 50 | 51 | let query1 = {a: 1, b: 2, c: 3}; 52 | let query2 = {c: 3, a: 1, b: 2}; 53 | 54 | let url1 = client.url('/foo/bar', query1); 55 | let url2 = client.url('/foo/bar', query2); 56 | 57 | expect(url1).to.equal(url2); 58 | }); 59 | 60 | }); 61 | 62 | describe('#get()', () => { 63 | it('sends a GET request given a relative path', () => { 64 | let fetch = stub(); 65 | let response = {ok: true, clone: () => response}; 66 | let client = new Client({fetch: {algo: fetch}}); 67 | 68 | fetch.withArgs(match.string, match.object).returns(P.resolve(response)); 69 | 70 | return client.get('/foo/bar') 71 | 72 | .then((res) => { 73 | expect(fetch.calledOnce).to.be.ok; 74 | expect(res).to.equal(response); 75 | }) 76 | }); 77 | 78 | it('sends a GET request given a fully qualified url', () => { 79 | let fetch = stub(); 80 | let response = {ok: true, clone: () => response}; 81 | let client = new Client({fetch: {algo: fetch}}); 82 | let url = 'http://prestashop-api-host/api/images/products/8'; 83 | 84 | fetch.withArgs(url, match.object).returns(P.resolve(response)); 85 | 86 | return client.get(url) 87 | 88 | .then((res) => { 89 | expect(fetch.calledOnce).to.be.ok; 90 | expect(res).to.equal(response); 91 | }) 92 | }); 93 | 94 | it('throws an Error on non-OK response', () => { 95 | let fetch = stub(); 96 | let response = {ok: false, clone: () => response}; 97 | let promise = P.resolve(response); 98 | let client = new Client({fetch: {algo: fetch}}); 99 | 100 | fetch.withArgs(match.string, match.object).returns(promise); 101 | 102 | return client.get('/foo/bar') 103 | .then(fail) 104 | .catch(pass); 105 | }); 106 | 107 | xit('concurrent requests on the same URL converge on a single promise', () => { 108 | let promise = P.resolve(); 109 | let fetch = stub().returns(promise); 110 | let client = new Client({fetch: {algo: fetch}}); 111 | let reqpath = '/foo/bar'; 112 | 113 | expect(client.get(reqpath)).to.equal(promise); 114 | expect(client.get(reqpath)).to.equal(promise); 115 | expect(fetch.calledOnce).to.be.ok; 116 | }); 117 | 118 | }); 119 | 120 | describe('root resource access', () => { 121 | let client = new Client(); 122 | 123 | describe('#resource()', () => { 124 | it('returns Products resource on key "products"', () => { 125 | expect(client.resource('products')).to.be.ok; 126 | }); 127 | it('returns Combinations resource on key "combinations"', () => { 128 | expect(client.resource('combinations')).to.be.ok; 129 | }); 130 | it('returns Manufacturers resource on key "manufacturers"', () => { 131 | expect(client.resource('manufacturers')).to.be.ok; 132 | }); 133 | it('returns Images resource on key "images"', () => { 134 | expect(client.resource('images')).to.be.ok; 135 | }); 136 | }) 137 | }); 138 | 139 | describe('#createAuthorizationHeader()', () => { 140 | it('returns Authorization header value', () => { 141 | let client = new Client(); 142 | let key = 'IW6SQL9FICVWMJ6BWBASP24ABCNSSEZW'; 143 | let expected = 'Basic SVc2U1FMOUZJQ1ZXTUo2QldCQVNQMjRBQkNOU1NFWlc6'; 144 | let actual = client.createAuthorizationHeader(key); 145 | 146 | expect(actual).to.equal(expected); 147 | }); 148 | }); 149 | 150 | describe('#createFetchOptions()', () => { 151 | it('includes Authorization header', () => { 152 | let client = new Client({webservice: {key: 'foo'}}); 153 | let Authorization = 'Basic SVc2U1FMOUZJQ1ZXTUo2QldCQVNQMjRBQkNOU1NFWlc6'; 154 | 155 | client.createAuthorizationHeader = stub().returns(Authorization); 156 | 157 | let fetchopts = client.createFetchOptions(); 158 | expect(fetchopts.headers).to.be.an('object'); 159 | expect(fetchopts.headers['Authorization']).to.equal(Authorization); 160 | }); 161 | }); 162 | 163 | describe('#setLanguageIso()', () => { 164 | it('changes the current language', () => { 165 | let client = new Client({languages: {en: 1, es: 2}}); 166 | assert(client.language != 2); 167 | client.setLanguageIso('es'); 168 | 169 | expect(client.language).to.equal(2); 170 | }); 171 | 172 | it('complains if the supplied ISO code is invalid', () => { 173 | let client = new Client({languages: {en: 1, es: 2}}); 174 | let invocation = () => client.setLanguageIso('zh'); 175 | 176 | expect(invocation).to.throw(Error); 177 | }); 178 | }); 179 | 180 | describe('#getLanguage()', () => { 181 | it('returns the current language ISO code', () => { 182 | let client = new Client({language: 'en', languages: {en: 1, es: 2}}); 183 | assert(client.language === 1); 184 | 185 | expect(client.getLanguageIso()).to.equal('en'); 186 | }); 187 | }); 188 | 189 | }); 190 | 191 | -------------------------------------------------------------------------------- /tests/unit/rest/resources/Images.spec.js: -------------------------------------------------------------------------------- 1 | import { resources } from '../../../../src/rest'; 2 | import { Image } from '../../../../src/models'; 3 | const { Images } = resources; 4 | const P = Promise; 5 | 6 | describe('rest.resources', () => { 7 | describe('Image', () => { 8 | describe('#list()', () => { 9 | 10 | it('returns a list of Image models', (done) => { 11 | let client = {get: stub()}; 12 | 13 | let resource = new Images({ 14 | client: client, 15 | root: '/images/products/8', 16 | }); 17 | 18 | let text = P.resolve(fixture('product-8-images.xml')); 19 | let response = {ok: true, text: stub().returns(text), clone: () => response}; 20 | 21 | client.get.withArgs('/images/products/8').returns(P.resolve(response)); 22 | 23 | resource.list() 24 | 25 | .then((models) => { 26 | expect(client.get.calledOnce).to.be.ok; 27 | expect(response.text.calledOnce).to.be.ok; 28 | expect(models).to.be.an.instanceof(Array); 29 | expect(models.length).to.equal(1); 30 | expect(models[0]).to.be.an.instanceof(Image); 31 | expect(models[0].attrs.src).to.equal('http://localhost/api/images/products/8/24'); 32 | }) 33 | 34 | .then(done) 35 | .catch(done) 36 | }); 37 | 38 | }); 39 | }); 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /tests/unit/rest/resources/Manufacturers.spec.js: -------------------------------------------------------------------------------- 1 | import { resources } from '../../../../src/rest'; 2 | import { Manufacturer } from '../../../../src/models'; 3 | 4 | const { Manufacturers } = resources; 5 | const P = Promise; 6 | const error = () => new Error(); 7 | 8 | describe('rest.resources', () => { 9 | describe('Manufacturers', () => { 10 | 11 | describe('#get()', () => { 12 | 13 | it('returns a single Manufacturer model', (done) => { 14 | let client = {get: stub()}; 15 | let resource = new Manufacturers({client}); 16 | 17 | let text = P.resolve(fixture('manufacturer-1.xml')); 18 | let response = {ok: true, text: stub().returns(text), clone: () => response}; 19 | 20 | client.get.withArgs('/manufacturers/1').returns(P.resolve(response)); 21 | 22 | resource.get(1) 23 | 24 | .then((model) => { 25 | expect(client.get.calledOnce).to.be.ok; 26 | expect(response.text.calledOnce).to.be.ok; 27 | expect(model).to.be.an.instanceof(Manufacturer); 28 | }) 29 | 30 | .then(done) 31 | .catch(done) 32 | }); 33 | 34 | }); 35 | 36 | describe('#list()', () => { 37 | 38 | it('returns a list of Manufacturer models', (done) => { 39 | let client = {get: stub()}; 40 | let resource = new Manufacturers({client: client}); 41 | 42 | // the manufacturer list response 43 | let response1 = {ok: true, text: stub().returns(fixture('manufacturers.xml')), clone: () => response1}; 44 | client.get.withArgs('/manufacturers').returns(P.resolve(response1)); 45 | 46 | // responses for each manufacturer id in the list response 47 | let response2 = {ok: true, text: stub().returns(fixture('manufacturer-1.xml')), clone: () => response2}; 48 | client.get.withArgs('/manufacturers/1').returns(P.resolve(response2)); 49 | 50 | resource.list() 51 | 52 | .then((models) => { 53 | expect(client.get.callCount).to.equal(2); 54 | expect(models).to.be.an.instanceof(Array); 55 | expect(models.length).to.equal(1); 56 | 57 | let [manufacturer1] = models; 58 | 59 | expect(manufacturer1.attrs.id).to.equal(1); 60 | }) 61 | 62 | .then(done) 63 | .catch(done) 64 | }); 65 | }); 66 | }); 67 | }); 68 | 69 | -------------------------------------------------------------------------------- /tests/unit/rest/resources/Products.spec.js: -------------------------------------------------------------------------------- 1 | import { resources } from '../../../../src/rest'; 2 | import { Product } from '../../../../src/models'; 3 | 4 | const { Products } = resources; 5 | const P = Promise; 6 | const error = () => new Error(); 7 | 8 | describe('rest.resources', () => { 9 | describe('Products', () => { 10 | describe('#get()', () => { 11 | 12 | it('returns a single Product model', (done) => { 13 | let client = {get: stub()}; 14 | let resource = new Products({client: client}); 15 | 16 | let text = P.resolve(fixture('product-8.xml')); 17 | let response = {ok: true, text: stub().returns(text), clone: () => response}; 18 | 19 | client.get.withArgs('/products/8').returns(P.resolve(response)); 20 | 21 | resource.get(8) 22 | 23 | .then((model) => { 24 | expect(client.get.calledOnce).to.be.ok; 25 | expect(response.text.calledOnce).to.be.ok; 26 | expect(model).to.be.an.instanceof(Product); 27 | }) 28 | 29 | .then(done) 30 | .catch(done) 31 | }); 32 | 33 | }); 34 | 35 | describe('#list()', () => { 36 | 37 | it('returns a list of Product models', (done) => { 38 | let client = {get: stub()}; 39 | let resource = new Products({client: client}); 40 | 41 | // the product list response 42 | let response1 = {ok: true, text: stub().returns(fixture('products.xml')), clone: () => response1}; 43 | client.get.withArgs('/products').returns(P.resolve(response1)); 44 | 45 | // responses for each product id in the list response 46 | let response2 = {ok: true, text: stub().returns(fixture('product-8.xml')), clone: () => response2}; 47 | let response3 = {ok: true, text: stub().returns(fixture('product-9.xml')), clone: () => response3}; 48 | client.get.withArgs('/products/8').returns(P.resolve(response2)); 49 | client.get.withArgs('/products/9').returns(P.resolve(response3)); 50 | 51 | resource.list() 52 | 53 | .then((models) => { 54 | expect(client.get.callCount).to.equal(3); 55 | expect(models).to.be.an.instanceof(Array); 56 | expect(models.length).to.equal(2); 57 | 58 | let [product8, product9] = models; 59 | 60 | expect(product8.attrs.id).to.equal(8); 61 | expect(product9.attrs.id).to.equal(9); 62 | }) 63 | 64 | .then(done) 65 | .catch(done) 66 | }); 67 | }); 68 | }); 69 | }); 70 | 71 | -------------------------------------------------------------------------------- /tests/unit/rest/resources/Resource.spec.js: -------------------------------------------------------------------------------- 1 | import { resources } from '../../../../src/rest'; 2 | import assert from 'assert'; 3 | const { Resource } = resources; 4 | const P = Promise; 5 | const DummyModel = class {}; 6 | 7 | describe('rest.resources', () => { 8 | describe('Resource', () => { 9 | 10 | describe('#language property', () => { 11 | it('reflects the client language', () => { 12 | let client = {language: 2}; 13 | let resource = new Resource({client}); 14 | 15 | expect(resource.language).to.equal(client.language); 16 | 17 | client.language = 3; 18 | expect(resource.language).to.equal(client.language); 19 | }); 20 | }); 21 | 22 | describe ('#get()', () => { 23 | it('returns a Model if client raises an exception', () => { 24 | let client = {get: stub().throws(new Error('http-related-error'))}; 25 | let Model = () => {}; 26 | let resource = new Resource({client, model: Model, root: '/foo/bar'}); 27 | 28 | assert(resource.client === client); 29 | 30 | return resource.get('blah') 31 | .then((model) => expect(model).to.be.an.instanceof(Model)) 32 | .catch((e) => { 33 | console.log(e); 34 | throw e; 35 | }); 36 | 37 | }); 38 | }); 39 | }); 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /tests/unit/sort.spec.js: -------------------------------------------------------------------------------- 1 | import sort from '../../src/sort'; 2 | const noop = () => {}; 3 | 4 | describe('sort', () => { 5 | 6 | describe('.ascending()', () => { 7 | it('accepts a value resolver function', () => { 8 | let invokation = () => sort.ascending(noop); 9 | expect(sort.ascending(() => {})).not.to.throw(); 10 | }) 11 | 12 | it('returns a sort comparator', () => { 13 | expect(sort.ascending(noop)).to.be.a('function'); 14 | }); 15 | 16 | it('comparator sorts in ascending order', () => { 17 | let values = [{prop: 3}, {prop: 1}, {prop: 2}]; 18 | let evaluate = value => value.prop; 19 | let comparator = sort.ascending(evaluate); 20 | let sorted = values.sort(comparator); 21 | 22 | expect(sorted[0].prop).to.equal(1); 23 | expect(sorted[1].prop).to.equal(2); 24 | expect(sorted[2].prop).to.equal(3); 25 | }); 26 | }); 27 | 28 | describe('.descending()', () => { 29 | it('accepts a value resolver function', () => { 30 | let invokation = () => sort.descending(noop); 31 | expect(sort.descending(() => {})).not.to.throw(); 32 | }) 33 | 34 | it('returns a sort comparator', () => { 35 | expect(sort.descending(noop)).to.be.a('function'); 36 | }); 37 | 38 | it('comparator sorts in descending order', () => { 39 | let values = [{prop: 3}, {prop: 1}, {prop: 2}]; 40 | let evaluate = value => value.prop; 41 | let comparator = sort.descending(evaluate); 42 | let sorted = values.sort(comparator); 43 | 44 | expect(sorted[0].prop).to.equal(3); 45 | expect(sorted[1].prop).to.equal(2); 46 | expect(sorted[2].prop).to.equal(1); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/unit/xml.spec.js: -------------------------------------------------------------------------------- 1 | import { parse } from '../../src/xml'; 2 | 3 | describe('xml', () => { 4 | 5 | describe('.parse()', () => { 6 | it('converts XML string to a plain object', () => { 7 | return parse(fixture('products.xml')) 8 | .then((obj) => expect(obj).to.be.ok) 9 | }); 10 | }); 11 | 12 | describe('.parse', () => { 13 | describe('.product.attributes()', () => { 14 | 15 | it('parses Product model attributes (product-8.xml)', () => { 16 | let xml = fixture('product-8.xml'); 17 | 18 | return parse.product.attributes(xml) 19 | 20 | .then((props) => { 21 | expect(Number(props.id)).to.equal(8); 22 | expect(props.name).to.equal('product-8-name-en'); 23 | expect(props.description).to.be.ok; 24 | expect(props.description).to.be.a('string'); 25 | expect(props.description_short).to.equal(''); 26 | expect(props.description_short).to.be.a('string'); 27 | expect(props.related.manufacturer).to.equal(0); 28 | expect(props.related.combinations).to.include(46, 47, 48, 49, 50, 51, 52, 53); 29 | expect(props.related.images).to.include(24); 30 | }) 31 | }); 32 | 33 | it('parses Product model attributes (product-10.xml)', () => { 34 | let xml = fixture('product-10.xml'); 35 | 36 | return parse.product.attributes(xml) 37 | 38 | .then((props) => { 39 | expect(Number(props.id)).to.equal(10); 40 | expect(props.name).to.equal('product-10-name-en'); 41 | expect(props.description).to.be.a('string'); 42 | expect(props.description_short).to.be.a('string'); 43 | expect(props.related.manufacturer).to.equal(0); 44 | expect(props.related.combinations).to.include(64, 65, 66, 67, 68, 69, 70, 71, 72, 73); 45 | expect(props.related.images).to.be.an.instanceof(Array); 46 | }) 47 | }); 48 | 49 | }); 50 | 51 | }); 52 | 53 | describe('.parse', () => { 54 | describe('.combination.attributes()', () => { 55 | it('parses Combination attributes', () => { 56 | let xml = fixture('combination-47.xml'); 57 | 58 | return parse.combination.attributes(xml) 59 | 60 | .then((props) => { 61 | let floatzero = 0.000000; 62 | 63 | expect(Number(props.id)).to.equal(47); 64 | expect(Number(props.id_product)).to.equal(8); 65 | expect(props.location).to.equal(''); 66 | expect(props.ean13).to.equal(''); 67 | expect(props.upc).to.equal(''); 68 | expect(Number(props.quantity)).to.equal(1); 69 | expect(props.reference).to.equal('001'); 70 | expect(props.supplier_reference).to.equal(''); 71 | expect(Number(props.wholesale_price)).to.equal(floatzero); 72 | expect(Number(props.price)).to.equal(floatzero); 73 | expect(Number(props.ecotax)).to.equal(floatzero); 74 | expect(Number(props.weight)).to.equal(floatzero); 75 | expect(Number(props.unit_price_impact)).to.equal(floatzero); 76 | expect(Number(props.minimal_quantity)).to.equal(1); 77 | expect(props.default_on).to.equal(''); 78 | expect(props.available_date).to.equal('0000-00-00'); 79 | expect(props.related.product).to.equal(8); 80 | expect(props.related.product_option_values).to.equal(26); 81 | }); 82 | }); 83 | }); 84 | }); 85 | 86 | describe('.parse', () => { 87 | describe('.manufacturer.attributes()', () => { 88 | it('parses Manufacturer attributes', () => { 89 | let xml = fixture('manufacturer-1.xml'); 90 | 91 | return parse.manufacturer.attributes(xml) 92 | 93 | .then((props) => { 94 | expect(Number(props.id)).to.equal(1); 95 | expect(Number(props.active)).to.equal(1); 96 | expect(props.name).to.equal('Fashion Manufacturer'); 97 | expect(props.description).to.equal(''); 98 | }); 99 | }); 100 | }); 101 | }); 102 | 103 | describe('.parse', () => { 104 | describe('.stock_available.attributes()', () => { 105 | it('parses StockAvailable attributes', () => { 106 | let xml = fixture('stock-available-80.xml'); 107 | 108 | return parse.stock_available.attributes(xml) 109 | 110 | .then((props) => { 111 | expect(Number(props.id)).to.equal(80); 112 | expect(Number(props.id_product)).to.equal(10); 113 | expect(Number(props.id_product_attribute)).to.equal(0); 114 | expect(Number(props.id_shop)).to.equal(1); 115 | expect(Number(props.id_shop_group)).to.equal(0); 116 | expect(Number(props.depends_on_stock)).to.equal(0); 117 | expect(Number(props.out_of_stock)).to.equal(2); 118 | }); 119 | }); 120 | }); 121 | }); 122 | 123 | 124 | describe('.parse', () => { 125 | describe('.product_option_value.attributes', () => { 126 | it('parses ProductOptionValue attributes', () => { 127 | let xml = fixture('product-option-values-25.xml'); 128 | 129 | return parse.product_option_value.attributes(xml) 130 | 131 | .then((props) => { 132 | expect(Number(props.id)).to.equal(25); 133 | expect(Number(props.id_attribute_group)).to.equal(4); 134 | expect(Number(props.position)).to.equal(0); 135 | expect(props.name).to.equal('1'); 136 | }); 137 | }); 138 | }); 139 | }); 140 | }); 141 | --------------------------------------------------------------------------------