From 48eb04eae2d3388386b9a62fa5260e11c2538e27 Mon Sep 17 00:00:00 2001 From: Nicolas Perriault Date: Mon, 18 Jul 2016 19:09:57 +0200 Subject: [PATCH] Fix #104: Add support for the attachment API. (#115) pair=@leplatrem --- .travis.yml | 1 + README.md | 42 +++++++++++++++++++- package.json | 20 ++++++---- src/base.js | 13 +++--- src/collection.js | 55 +++++++++++++++++++++++--- src/endpoint.js | 20 +++++++--- src/http.js | 2 +- src/requests.js | 31 ++++++++++++++- src/utils.js | 65 ++++++++++++++++++++++++++++-- test/api_test.js | 43 ++++++++++---------- test/http_test.js | 29 +++++++------- test/integration_test.js | 85 ++++++++++++++++++++++++++++++++++++++-- test/kinto.ini | 9 ++++- test/requests_test.js | 24 ++++++++++++ test/setup-jsdom.js | 18 +++++++++ test/utils_test.js | 50 ++++++++++++++++++++--- 16 files changed, 428 insertions(+), 79 deletions(-) create mode 100644 test/setup-jsdom.js diff --git a/.travis.yml b/.travis.yml index 553d9523..fdcccb38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,7 @@ before_install: - virtualenv .env - if [[ $SERVER && $SERVER != development ]]; then .env/bin/pip install kinto==$SERVER; fi - if [[ $SERVER && $SERVER == development ]]; then .env/bin/pip install https://github.com/Kinto/kinto/zipball/master; fi +- .env/bin/pip install kinto-attachment - export KINTO_PSERVE_EXECUTABLE=.env/bin/pserve script: - npm $ACTION diff --git a/README.md b/README.md index 103e4628..030bd546 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,11 @@ Read the [API documentation](https://doc.esdoc.org/github.com/Kinto/kinto-http.j - [Deleting record](#deleting-record) - [Listing records](#listing-records) - [Batching operations](#batching-operations) - - [Options](#options) + - [Attachments](#attachments) + - [Adding an attachment to a record](#adding-an-attachment-to-a-record) + - [Updating an attachment](#updating-an-attachment) + - [Deleting an attachment](#deleting-an-attachment) + - [Generic bucket and collection options](#generic-bucket-and-collection-options) - [The safe option explained](#the-safe-option-explained) - [Safe creations](#safe-creations) - [Safe updates](#safe-updates) @@ -1191,7 +1195,41 @@ Sample result: } ``` -## Options +## Attachments + +If the [attachment](https://github.com/Kinto/kinto-attachment) capability is available from the Kinto server, you can attach files to records. Files must be passed as [data urls](http://dataurl.net/#about), which can be generated using the [FileReader API](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL) in the browser. + +### Adding an attachment to a record + +```js +client.bucket("blog").collection("posts") + .addAttachment(dataURL, {title: "First post"}); +``` + +#### Options + +- `headers`: Custom headers object to send along the HTTP request; +- `safe`: Ensures operations won't override existing resources on the server if their associated `last_modified` value or option are provided; otherwise ensures resources won't be overriden if they already exist on the server; +- `last_modified`: When `safe` is true, the last timestamp we know the resource has been updated on the server; +- `permissions`: Permissions to be set on the record; +- `filename`: Allows to specify the attachment filename, in case the data URI does not contain any, or if the file has to be renamed on upload; + + +### Updating an attachment + +```js +client.bucket("blog").collection("posts") + .addAttachment(dataURL, {id: "22c1319e-7b09-46db-bec4-c240bdf4e3e9"}); +``` + +### Deleting an attachment + +```js +client.bucket("blog").collection("posts") + .removeAttachment("22c1319e-7b09-46db-bec4-c240bdf4e3e9"); +``` + +## Generic bucket and collection options Both `bucket()` and `collection()` methods accept an `options` object as a second arguments where you can define the following options: diff --git a/package.json b/package.json index b310df27..62a609fe 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,20 @@ "main": "lib/index.js", "scripts": { "build": "babel -d lib/ src/", + "build:readme": "./node_modules/.bin/toctoc -w -d 2 README.md", "dist": "mkdir -p dist && rm -f dist/*.* && npm run dist-dev && npm run dist-prod && npm run dist-noshim && npm run dist-fx", "dist-dev": "browserify -s KintoClient -d -e src/index.js -o dist/kinto-http.js -t [ babelify --sourceMapRelative . ]", "dist-noshim": "browserify -s KintoClient -g uglifyify --ignore isomorphic-fetch --ignore babel-polyfill -e src/index.js -o dist/kinto-http.noshim.js -t [ babelify --sourceMapRelative . ]", "dist-prod": "browserify -s KintoClient -g uglifyify -e src/index.js -o dist/kinto-http.min.js -t [ babelify --sourceMapRelative . ]", "dist-fx": "BABEL_ENV=firefox browserify -s KintoHttpClient --ignore isomorphic-fetch --ignore events --bare -e fx-src/index.js -o temp.jsm -t [ babelify --sourceMapRelative . ] && mkdir -p dist && cp fx-src/jsm_prefix.js dist/moz-kinto-http-client.js && echo \"\n/*\n * Version $npm_package_version - $(git rev-parse --short HEAD)\n */\n\" >> dist/moz-kinto-http-client.js && cat temp.jsm >> dist/moz-kinto-http-client.js && rm temp.jsm", - "prepublish": "node_modules/.bin/toctoc -w -d 2 README.md", + "prepublish": "npm run build:readme", "publish-to-npm": "npm run build && npm run dist && npm publish", "report-coverage": "npm run test-cover && ./node_modules/coveralls/bin/coveralls.js < ./coverage/lcov.info", - "tdd": "babel-node node_modules/.bin/_mocha --watch 'test/**/*_test.js'", + "tdd": "babel-node node_modules/.bin/_mocha --require ./test/setup-jsdom.js --watch 'test/**/*_test.js'", "test": "npm run test-nocover", - "test-cover": "babel-node node_modules/.bin/babel-istanbul cover --report text $npm_package_config_ISTANBUL_OPTS node_modules/.bin/_mocha -- 'test/**/*_test.js'", - "test-cover-html": "babel-node node_modules/.bin/babel-istanbul cover --report html $npm_package_config_ISTANBUL_OPTS node_modules/.bin/_mocha -- 'test/**/*_test.js' && open coverage/index.html", - "test-nocover": "babel-node node_modules/.bin/_mocha 'test/**/*_test.js'", + "test-cover": "babel-node node_modules/.bin/babel-istanbul cover --report text $npm_package_config_ISTANBUL_OPTS node_modules/.bin/_mocha -- --require ./test/setup-jsdom.js 'test/**/*_test.js'", + "test-cover-html": "babel-node node_modules/.bin/babel-istanbul cover --report html $npm_package_config_ISTANBUL_OPTS node_modules/.bin/_mocha -- --require ./test/setup-jsdom.js 'test/**/*_test.js' && open coverage/index.html", + "test-nocover": "babel-node node_modules/.bin/_mocha --require ./test/setup-jsdom.js 'test/**/*_test.js'", "lint": "eslint src test" }, "repository": { @@ -37,9 +38,11 @@ }, "homepage": "https://github.com/Kinto/kinto-http.js#readme", "dependencies": { - "isomorphic-fetch": "^2.2.1" + "isomorphic-fetch": "^2.2.1", + "uuid": "^2.0.1" }, "devDependencies": { + "atob": "^2.0.3", "babel-cli": "^6.6.5", "babel-core": "^6.6.5", "babel-eslint": "^5.0.0-beta6", @@ -60,12 +63,13 @@ "esdoc-es7-plugin": "0.0.3", "esdoc-importpath-plugin": "0.0.1", "eslint": "2.2.0", + "form-data": "^1.0.0-rc4", + "jsdom": "^9.4.1", "kinto-node-test-server": "0.0.1", "mocha": "^2.3.4", "sinon": "^1.17.2", "toctoc": "^0.2.2", - "uglifyify": "^3.0.1", - "uuid": "^2.0.1" + "uglifyify": "^3.0.1" }, "engines": { "node": ">=6" diff --git a/src/base.js b/src/base.js index d187aa55..ee21d23a 100644 --- a/src/base.js +++ b/src/base.js @@ -343,10 +343,13 @@ export default class KintoClientBase { * @private * @param {Object} request The request object. * @param {Object} [options={}] The options object. - * @param {Boolean} [options.raw=false] If true, resolve with full response object, including json body and headers instead of just json. + * @param {Boolean} [options.raw=false] If true, resolve with full response + * @param {Boolean} [options.stringify=true] If true, serialize body data to + * JSON. * @return {Promise} */ - execute(request, options={raw: false}) { + execute(request, options={raw: false, stringify: true}) { + const {raw, stringify} = options; // If we're within a batch, add the request to the stack to send at once. if (this._isBatch) { this._requests.push(request); @@ -354,16 +357,16 @@ export default class KintoClientBase { // from within a batch operation. const msg = "This result is generated from within a batch " + "operation and should not be consumed."; - return Promise.resolve(options.raw ? {json: msg} : msg); + return Promise.resolve(raw ? {json: msg} : msg); } const promise = this.fetchServerSettings() .then(_ => { return this.http.request(this.remote + request.path, { ...request, - body: JSON.stringify(request.body) + body: stringify ? JSON.stringify(request.body) : request.body, }); }); - return options.raw ? promise : promise.then(({json}) => json); + return raw ? promise : promise.then(({json}) => json); } /** diff --git a/src/collection.js b/src/collection.js index a3608af9..5413c85e 100644 --- a/src/collection.js +++ b/src/collection.js @@ -1,4 +1,6 @@ -import { toDataBody, qsify, isObject } from "./utils"; +import { v4 as uuid } from "uuid"; + +import { capable, toDataBody, qsify, isObject } from "./utils"; import * as requests from "./requests"; import endpoint from "./endpoint"; @@ -150,10 +152,11 @@ export default class Collection { /** * Creates a record in current collection. * - * @param {Object} record The record to create. - * @param {Object} [options={}] The options object. - * @param {Object} [options.headers] The headers object option. - * @param {Boolean} [options.safe] The safe option. + * @param {Object} record The record to create. + * @param {Object} [options={}] The options object. + * @param {Object} [options.headers] The headers object option. + * @param {Boolean} [options.safe] The safe option. + * @param {Object} [options.permissions] The permissions option. * @return {Promise} */ createRecord(record, options={}) { @@ -164,6 +167,47 @@ export default class Collection { return this.client.execute(request); } + /** + * Adds an attachment to a record, creating the record when it doesn't exist. + * + * @param {String} dataURL The data url. + * @param {Object} [record={}] The record data. + * @param {Object} [options={}] The options object. + * @param {Object} [options.headers] The headers object option. + * @param {Boolean} [options.safe] The safe option. + * @param {Number} [options.last_modified] The last_modified option. + * @param {Object} [options.permissions] The permissions option. + * @param {String} [options.filename] Force the attachment filename. + * @return {Promise} + */ + @capable(["attachments"]) + addAttachment(dataURI, record={}, options={}) { + const reqOptions = this._collOptions(options); + const {permissions} = reqOptions; + const id = record.id || uuid.v4(); + const path = endpoint("attachment", this.bucket.name, this.name, id); + const addAttachmentRequest = requests.addAttachmentRequest(path, dataURI, {data: record, permissions}, reqOptions); + return this.client.execute(addAttachmentRequest, {stringify: false}) + .then(() => this.getRecord(id)); + } + + /** + * Removes an attachment from a given record. + * + * @param {Object} recordId The record id. + * @param {Object} [options={}] The options object. + * @param {Object} [options.headers] The headers object option. + * @param {Boolean} [options.safe] The safe option. + * @param {Number} [options.last_modified] The last_modified option. + */ + @capable(["attachments"]) + removeAttachment(recordId, options={}) { + const reqOptions = this._collOptions(options); + const path = endpoint("attachment", this.bucket.name, this.name, recordId); + const request = requests.deleteRequest(path, reqOptions); + return this.client.execute(request); + } + /** * Updates a record in current collection. * @@ -172,6 +216,7 @@ export default class Collection { * @param {Object} [options.headers] The headers object option. * @param {Boolean} [options.safe] The safe option. * @param {Number} [options.last_modified] The last_modified option. + * @param {Object} [options.permissions] The permissions option. * @return {Promise} */ updateRecord(record, options={}) { diff --git a/src/endpoint.js b/src/endpoint.js index cd082b75..f6299381 100644 --- a/src/endpoint.js +++ b/src/endpoint.js @@ -3,12 +3,20 @@ * @type {Object} */ const ENDPOINTS = { - root: () => "/", - batch: () => "/batch", - bucket: (bucket) => "/buckets" + (bucket ? `/${bucket}` : ""), - collection: (bucket, coll) => `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""), - group: (bucket, group) => `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""), - record: (bucket, coll, id) => `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""), + root: () => + "/", + batch: () => + "/batch", + bucket: (bucket) => + "/buckets" + (bucket ? `/${bucket}` : ""), + collection: (bucket, coll) => + `${ENDPOINTS.bucket(bucket)}/collections` + (coll ? `/${coll}` : ""), + group: (bucket, group) => + `${ENDPOINTS.bucket(bucket)}/groups` + (group ? `/${group}` : ""), + record: (bucket, coll, id) => + `${ENDPOINTS.collection(bucket, coll)}/records` + (id ? `/${id}` : ""), + attachment: (bucket, coll, id) => + `${ENDPOINTS.record(bucket, coll, id)}/attachment`, }; /** diff --git a/src/http.js b/src/http.js index 9ed9c9b2..47320afa 100644 --- a/src/http.js +++ b/src/http.js @@ -77,7 +77,7 @@ export default class HTTP { request(url, options={headers:{}}) { let response, status, statusText, headers, hasTimedout; // Ensure default request headers are always set - options.headers = Object.assign({}, HTTP.DEFAULT_REQUEST_HEADERS, options.headers); + options.headers = {...HTTP.DEFAULT_REQUEST_HEADERS, ...options.headers}; options.mode = this.requestMode; return new Promise((resolve, reject) => { const _timeoutId = setTimeout(() => { diff --git a/src/requests.js b/src/requests.js index e07e08e2..80972b23 100644 --- a/src/requests.js +++ b/src/requests.js @@ -1,4 +1,5 @@ -import { omit } from "./utils"; +import { omit, createFormData } from "./utils"; + const requestDefaults = { safe: false, @@ -74,7 +75,7 @@ export function updateRequest(path, {data, permissions}, options={}) { * @private */ export function deleteRequest(path, options={}) { - const { headers, safe, last_modified} = { + const {headers, safe, last_modified} = { ...requestDefaults, ...options }; @@ -87,3 +88,29 @@ export function deleteRequest(path, options={}) { headers: {...headers, ...safeHeader(safe, last_modified)} }; } + +/** + * @private + */ +export function addAttachmentRequest(path, dataURI, {data, permissions}={}, options={}) { + const {headers, safe} = {...requestDefaults, ...options}; + const {last_modified} = {...data, ...options }; + if (safe && !last_modified) { + throw new Error("Safe concurrency check requires a last_modified value."); + } + + const body = {data, permissions}; + const formData = createFormData(dataURI, body, options); + + return { + method: "POST", + path, + headers: { + ...headers, + ...safeHeader(safe, last_modified), + // Setting content type as undefined so that it does not use default "application/json". + "Content-Type": undefined + }, + body: formData + }; +} diff --git a/src/utils.js b/src/utils.js index bf7d799b..70769957 100644 --- a/src/utils.js +++ b/src/utils.js @@ -140,7 +140,7 @@ export function support(min, max) { const client = "client" in this ? this.client : this; return client.fetchHTTPApiVersion() .then(version => checkVersion(version, min, max)) - .then(Promise.resolve(fn.apply(this, args))); + .then(() => fn.apply(this, args)); }; Object.defineProperty(this, key, { value: wrappedMethod, @@ -171,13 +171,13 @@ export function capable(capabilities) { const client = "client" in this ? this.client : this; return client.fetchServerCapabilities() .then(available => { - const missing = capabilities.filter(c => available.indexOf(c) < 0); + const missing = capabilities.filter(c => !available.hasOwnProperty(c)); if (missing.length > 0) { throw new Error(`Required capabilities ${missing.join(", ")} ` + "not present on server"); } }) - .then(Promise.resolve(fn.apply(this, args))); + .then(() => fn.apply(this, args)); }; Object.defineProperty(this, key, { value: wrappedMethod, @@ -229,3 +229,62 @@ export function nobatch(message) { export function isObject(thing) { return typeof thing === "object" && thing !== null && !Array.isArray(thing); } + +/** + * Parses a data url. + * @param {String} dataURL The data url. + * @return {Object} + */ +export function parseDataURL(dataURL) { + const regex = /^data:(.*);base64,(.*)/; + const match = dataURL.match(regex); + if (!match) { + throw new Error(`Invalid data-url: ${String(dataURL).substr(0, 32)}...`); + } + const props = match[1]; + const base64 = match[2]; + const [type, ...rawParams] = props.split(";"); + const params = rawParams.reduce((acc, param) => { + const [key, value] = param.split("="); + return {...acc, [key]: value}; + }, {}); + return {...params, type, base64}; +} + +/** + * Extracts file information from a data url. + * @param {String} dataURL The data url. + * @return {Object} + */ +export function extractFileInfo(dataURL) { + const {name, type, base64} = parseDataURL(dataURL); + const binary = atob(base64); + const array = []; + for(let i = 0; i < binary.length; i++) { + array.push(binary.charCodeAt(i)); + } + const blob = new Blob([new Uint8Array(array)], {type}); + return {blob, name}; +} + +/** + * Creates a FormData instance from a data url and an existing JSON response + * body. + * @param {String} dataURL The data url. + * @param {Object} body The response body. + * @param {Object} [options={}] The options object. + * @param {Object} [options.filename] Force attachment file name. + * @return {FormData} + */ +export function createFormData(dataURL, body, options={}) { + const {filename="untitled"} = options; + const {blob, name} = extractFileInfo(dataURL); + const formData = new FormData(); + formData.append("attachment", blob, name || filename); + for (const property in body) { + if (typeof body[property] !== "undefined") { + formData.append(property, JSON.stringify(body[property])); + } + } + return formData; +} diff --git a/test/api_test.js b/test/api_test.js index 7c284960..b8bdae98 100644 --- a/test/api_test.js +++ b/test/api_test.js @@ -14,7 +14,6 @@ chai.use(chaiAsPromised); chai.should(); chai.config.includeStack = true; -const root = typeof window === "object" ? window : global; const FAKE_SERVER_URL = "http://fake-server/v1"; /** @test {KintoClient} */ @@ -115,7 +114,7 @@ describe("KintoClient", () => { it("should provide the remaining backoff time in ms if any", () => { // Make Date#getTime always returning 1000000, for predictability sandbox.stub(Date.prototype, "getTime").returns(1000 * 1000); - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {}, {Backoff: "1000"})); return api.listBuckets() @@ -123,7 +122,7 @@ describe("KintoClient", () => { }); it("should provide no remaining backoff time when none is set", () => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(200, {}, {})); + sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {})); return api.listBuckets() .then(_ => expect(api.backoff).eql(0)); @@ -150,7 +149,7 @@ describe("KintoClient", () => { const fakeServerInfo = {fake: true}; it("should retrieve server settings on first request made", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, fakeServerInfo)); return api.fetchServerInfo() @@ -159,14 +158,14 @@ describe("KintoClient", () => { it("should store server settings into the serverSettings property", () => { api.serverSettings = {a: 1}; - sandbox.stub(root, "fetch"); + sandbox.stub(global, "fetch"); api.fetchServerInfo(); }); it("should not fetch server settings if they're cached already", () => { api.serverInfo = fakeServerInfo; - sandbox.stub(root, "fetch"); + sandbox.stub(global, "fetch"); api.fetchServerInfo(); sinon.assert.notCalled(fetch); @@ -178,7 +177,7 @@ describe("KintoClient", () => { const fakeServerInfo = {settings: {fake: true}}; it("should retrieve server settings", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, fakeServerInfo)); return api.fetchServerSettings() @@ -191,7 +190,7 @@ describe("KintoClient", () => { const fakeServerInfo = {capabilities: {fake: true}}; it("should retrieve server capabilities", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, fakeServerInfo)); return api.fetchServerCapabilities() @@ -204,7 +203,7 @@ describe("KintoClient", () => { const fakeServerInfo = {user: {fake: true}}; it("should retrieve user information", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, fakeServerInfo)); return api.fetchUser() @@ -217,7 +216,7 @@ describe("KintoClient", () => { const fakeServerInfo = {http_api_version: {fake: true}}; it("should retrieve current API version", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, fakeServerInfo)); return api.fetchHTTPApiVersion() @@ -255,7 +254,7 @@ describe("KintoClient", () => { let requestBody, requestHeaders; beforeEach(() => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(200, { + sandbox.stub(global, "fetch").returns(fakeServerResponse(200, { responses: [] })); }); @@ -345,7 +344,7 @@ describe("KintoClient", () => { ]; it("should reject on HTTP 400", () => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(400, { + sandbox.stub(global, "fetch").returns(fakeServerResponse(400, { error: true, errno: 117, message: "http 400" @@ -356,7 +355,7 @@ describe("KintoClient", () => { }); it("should reject on HTTP error status code", () => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(500, { + sandbox.stub(global, "fetch").returns(fakeServerResponse(500, { error: true, message: "http 500" })); @@ -374,7 +373,7 @@ describe("KintoClient", () => { path: `/${SPV}/buckets/blog/collections/articles/records`, body: { data: fixtures[1]}}, ]; - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, {responses})); return executeBatch(fixtures) @@ -390,7 +389,7 @@ describe("KintoClient", () => { body: missingRemotely }, ]; - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, {responses})); return executeBatch(fixtures) @@ -405,7 +404,7 @@ describe("KintoClient", () => { body: { 500: true } }, ]; - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, {responses})); return executeBatch(fixtures) @@ -424,7 +423,7 @@ describe("KintoClient", () => { } }, ]; - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, {responses})); return executeBatch(fixtures) @@ -442,7 +441,7 @@ describe("KintoClient", () => { ]; it("should chunk batch requests", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .onFirstCall().returns(fakeServerResponse(200, { responses: [ {status: 200, body: {data: 1}}, @@ -464,7 +463,7 @@ describe("KintoClient", () => { api.fetchServerSettings.returns(Promise.resolve({ "batch_max_requests": null })); - sandbox.stub(root, "fetch").returns(fakeServerResponse(200, { + sandbox.stub(global, "fetch").returns(fakeServerResponse(200, { responses: [] })); return executeBatch(fixtures) @@ -472,7 +471,7 @@ describe("KintoClient", () => { }); it("should map initial records to conflict objects", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .onFirstCall().returns(fakeServerResponse(200, { responses: [ {status: 412, body: {details: {existing: {id: 1}}}}, @@ -491,7 +490,7 @@ describe("KintoClient", () => { }); it("should chunk batch requests concurrently", () => { - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .onFirstCall().returns(new Promise(resolve => { setTimeout(() => { resolve(fakeServerResponse(200, { @@ -528,7 +527,7 @@ describe("KintoClient", () => { it("should resolve with an aggregated result object", () => { const responses = []; - sandbox.stub(root, "fetch") + sandbox.stub(global, "fetch") .returns(fakeServerResponse(200, {responses})); const batchModule = require("../src/batch"); const aggregate = sandbox.stub(batchModule, "aggregate"); diff --git a/test/http_test.js b/test/http_test.js index 4a25b457..c72f2f93 100644 --- a/test/http_test.js +++ b/test/http_test.js @@ -7,12 +7,11 @@ import { EventEmitter } from "events"; import { fakeServerResponse } from "./test_utils.js"; import HTTP from "../src/http.js"; + chai.use(chaiAsPromised); chai.should(); chai.config.includeStack = true; -const root = typeof window === "object" ? window : global; - /** @test {HTTP} */ describe("HTTP class", () => { let sandbox, events, http; @@ -48,7 +47,7 @@ describe("HTTP class", () => { describe("#request()", () => { describe("Request headers", () => { beforeEach(() => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(200, {}, {})); + sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {})); }); it("should set default headers", () => { @@ -67,7 +66,7 @@ describe("HTTP class", () => { describe("Request CORS mode", () => { beforeEach(() => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(200, {}, {})); + sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {})); }); it("should use default CORS mode", () => { @@ -87,7 +86,7 @@ describe("HTTP class", () => { describe("Succesful request", () => { beforeEach(() => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {a: 1}, {b: 2})); }); @@ -112,7 +111,7 @@ describe("HTTP class", () => { describe("Request timeout", () => { it("should timeout the request", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( new Promise(resolve => { setTimeout(resolve, 20000); })); @@ -123,7 +122,7 @@ describe("HTTP class", () => { describe("No content response", () => { it("should resolve with null JSON if Content-Length header is missing", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, null, {})); return http.request("/") @@ -134,7 +133,7 @@ describe("HTTP class", () => { describe("Malformed JSON response", () => { it("should reject with an appropriate message", () => { - sandbox.stub(root, "fetch").returns(Promise.resolve({ + sandbox.stub(global, "fetch").returns(Promise.resolve({ status: 200, headers: { get(name) { @@ -155,7 +154,7 @@ describe("HTTP class", () => { describe("Business error responses", () => { it("should reject on status code > 400", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(400, { code: 400, details: [ @@ -189,7 +188,7 @@ describe("HTTP class", () => { }); it("should handle deprecation header", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {}, {Alert: JSON.stringify(eolObject)})); return http.request("/") @@ -201,7 +200,7 @@ describe("HTTP class", () => { }); it("should handle deprecation header parse error", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {}, {Alert: "dafuq"})); return http.request("/") @@ -213,7 +212,7 @@ describe("HTTP class", () => { }); it("should emit a deprecated event on Alert header", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {}, {Alert: JSON.stringify(eolObject)})); return http.request("/").then(_ => { @@ -231,7 +230,7 @@ describe("HTTP class", () => { }); it("should emit a backoff event on set Backoff header", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {}, {Backoff: "1000"})); return http.request("/").then(_ => { @@ -241,7 +240,7 @@ describe("HTTP class", () => { }); it("should emit a backoff event on missing Backoff header", () => { - sandbox.stub(root, "fetch").returns(fakeServerResponse(200, {}, {})); + sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {})); return http.request("/").then(_ => { expect(events.emit.firstCall.args[0]).eql("backoff"); @@ -258,7 +257,7 @@ describe("HTTP class", () => { }); it("should emit a retry-after event when Retry-After is set", () => { - sandbox.stub(root, "fetch").returns( + sandbox.stub(global, "fetch").returns( fakeServerResponse(200, {}, {"Retry-After": "1000"})); return http.request("/").then(_ => { diff --git a/test/integration_test.js b/test/integration_test.js index df83e95e..c24edceb 100644 --- a/test/integration_test.js +++ b/test/integration_test.js @@ -1,6 +1,5 @@ "use strict"; -import btoa from "btoa"; import chai, { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; import sinon from "sinon"; @@ -24,7 +23,10 @@ describe("Integration tests", function() { this.timeout(0); before(() => { - server = new KintoServer(TEST_KINTO_SERVER, {maxAttempts: 200}); + server = new KintoServer(TEST_KINTO_SERVER, { + maxAttempts: 200, + kintoConfigPath: __dirname + "/kinto.ini", + }); }); after(() => server.killAll()); @@ -97,7 +99,7 @@ describe("Integration tests", function() { // Kinto protocol 1.4 exposes capability descriptions Object.keys(capabilities).forEach(capability => { const capabilityObj = capabilities[capability]; - expect(capabilityObj).to.have.key("url", "description"); + expect(capabilityObj).to.include.keys("url", "description"); }); }); }); @@ -979,6 +981,83 @@ describe("Integration tests", function() { }); }); + describe(".addAttachment()", () => { + describe("With filename", () => { + const input = "test"; + const dataURL = "data:text/plain;name=test.txt;base64," + btoa(input); + + let result; + + beforeEach(() => { + return coll + .addAttachment(dataURL, {foo: "bar"}, { + permissions: {write: ["github:n1k0"]} + }) + .then(res => result = res); + }); + + it("should create a record with an attachment", () => { + expect(result) + .to.have.property("data") + .to.have.property("attachment") + .to.have.property("size").eql(input.length); + }); + + it("should create a record with provided record data", () => { + expect(result) + .to.have.property("data") + .to.have.property("foo").eql("bar"); + }); + + it("should create a record with provided permissions", () => { + expect(result) + .to.have.property("permissions") + .to.have.property("write").contains("github:n1k0"); + }); + }); + + describe("Without filename", () => { + it("should default filename to 'untitled' if not specified", () => { + const dataURL = "data:text/plain;base64," + btoa("blah"); + return coll + .addAttachment(dataURL) + .should.eventually + .have.property("data") + .have.property("attachment") + .have.property("filename").eql("untitled"); + }); + + it("should allow to specify a filename in options", () => { + const dataURL = "data:text/plain;base64," + btoa("blah"); + return coll + .addAttachment(dataURL, {}, {filename: "MYFILE.DAT"}) + .should.eventually + .have.property("data") + .have.property("attachment") + .have.property("filename").eql("MYFILE.DAT"); + }); + }); + }); + + describe(".removeAttachment()", () => { + const input = "test"; + const dataURL = "data:text/plain;name=test.txt;base64," + btoa(input); + + let recordId; + + beforeEach(() => { + return coll.addAttachment(dataURL) + .then(res => recordId = res.data.id); + }); + + it("should remove an attachment from a record", () => { + return coll.removeAttachment(recordId) + .then(() => coll.getRecord(recordId)) + .should.eventually.have.property("data") + .to.have.property("attachment").eql(null); + }); + }); + describe(".getRecord()", () => { it("should retrieve a record by its id", () => { return coll.createRecord({title: "blah"}) diff --git a/test/kinto.ini b/test/kinto.ini index b919ff88..7ca489dc 100644 --- a/test/kinto.ini +++ b/test/kinto.ini @@ -10,8 +10,15 @@ kinto.userid_hmac_secret = a-secret-string # Allow browsing all buckets kinto.bucket_read_principals = system.Authenticated -# Add default bucket feature +# Plugins registration kinto.includes = kinto.plugins.default_bucket + kinto_attachment + +# Kinto-attachment +kinto.attachment.base_url = http://0.0.0.0:8888/attachments +kinto.attachment.folder = {bucket_id}/{collection_id} +kinto.attachment.keep_old_files = true +kinto.attachment.base_path = /tmp [server:main] use = egg:waitress#main diff --git a/test/requests_test.js b/test/requests_test.js index f7d4b5c4..723ef635 100644 --- a/test/requests_test.js +++ b/test/requests_test.js @@ -135,4 +135,28 @@ describe("requests module", () => { .to.have.property("method").eql("PATCH"); }); }); + + describe("addAttachmentRequest()", () => { + const dataURL = "data:text/plain;name=test.txt;base64," + btoa("hola"); + it("should return a post request", () => { + expect(requests.addAttachmentRequest("/foo", dataURL)) + .to.have.property("method").eql("POST"); + }); + + it("should accept a headers option", () => { + expect(requests.addAttachmentRequest("/foo", dataURL, {}, {headers: {Foo: "Bar"}})) + .to.have.property("headers").eql({"Content-Type": undefined, Foo: "Bar"}); + }); + + it("should raise for safe with no last_modified passed", () => { + expect(() => requests.addAttachmentRequest("/foo", dataURL, {}, {safe: true})) + .to.Throw(Error, /requires a last_modified/); + }); + + it("should support a safe option with a last_modified option", () => { + expect(requests.addAttachmentRequest("/foo", dataURL, {}, {safe: true, last_modified: 42})) + .to.have.property("headers") + .to.have.property("If-Match").eql("\"42\""); + }); + }); }); diff --git a/test/setup-jsdom.js b/test/setup-jsdom.js new file mode 100644 index 00000000..491dd960 --- /dev/null +++ b/test/setup-jsdom.js @@ -0,0 +1,18 @@ +const jsdom = require("jsdom"); + +// Setup the jsdom environment +// @see https://github.com/facebook/react/issues/5046 +global.document = jsdom.jsdom(""); +global.window = document.defaultView; +global.navigator = global.window.navigator; + +// Expose a global fetch polyfill +global.fetch = global.window.fetch = require("isomorphic-fetch"); + +// jsdom FormData & Blob implementations are inconsistent, exposing better ones +global.FormData = require("form-data"); +global.Blob = (sequences) => Buffer.from(sequences[0]); + +// atob & btoa polyfill for tests +global.atob = require("atob"); +global.btoa = require("btoa"); diff --git a/test/utils_test.js b/test/utils_test.js index 014ddd59..69e06db6 100644 --- a/test/utils_test.js +++ b/test/utils_test.js @@ -10,7 +10,9 @@ import { checkVersion, support, capable, - nobatch + nobatch, + parseDataURL, + extractFileInfo, } from "../src/utils"; chai.should(); @@ -217,10 +219,10 @@ describe("Utils", () => { it("should make decorated method resolve on capability match", () => { class FakeClient { fetchServerCapabilities() { - return Promise.resolve(["default", "attachment", "auth:fxa"]); + return Promise.resolve({attachments: {}, default: {}, "auth:fxa": {}}); } - @capable(["default", "attachment"]) + @capable(["default", "attachments"]) test() { return Promise.resolve(); } @@ -232,10 +234,10 @@ describe("Utils", () => { it("should make decorated method rejecting on missing capability", () => { class FakeClient { fetchServerCapabilities() { - return Promise.resolve(["attachment"]); + return Promise.resolve({attachments: {}}); } - @capable(["attachment", "default"]) + @capable(["attachments", "default"]) test() { return Promise.resolve(); } @@ -249,7 +251,7 @@ describe("Utils", () => { constructor() { this.client = { fetchServerCapabilities() { - return Promise.resolve(["default"]); + return Promise.resolve({default: {}}); } }; } @@ -299,4 +301,40 @@ describe("Utils", () => { expect(() => new FakeClient().test()).to.Throw(Error, "error"); }); }); + + describe("parseDataURL()", () => { + it("should extract expected properties", () => { + expect(parseDataURL("data:image/png;encoding=utf-8;name=a.png;base64,b64")) + .eql({ + type: "image/png", + name: "a.png", + base64: "b64", + encoding: "utf-8", + }); + }); + + it("should support dataURL without name", () => { + expect(parseDataURL("data:image/png;base64,b64")) + .eql({ + type: "image/png", + base64: "b64", + }); + }); + + it("should throw an error when the data url is invalid", () => { + expect(() => expect(parseDataURL("gni"))) + .to.throw(Error, "Invalid data-url: gni..."); + }); + }); + + describe("extractFileInfo()", () => { + it("should extract file information from a data url", () => { + const dataURL = "data:text/plain;name=t.txt;base64," + btoa("test"); + + const {blob, name} = extractFileInfo(dataURL); + + expect(blob.length).eql(4); + expect(name).eql("t.txt"); + }); + }); });