diff --git a/Makefile b/Makefile deleted file mode 100644 index 1061667..0000000 --- a/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -TESTS = $(shell find tests/*.test.js) - -test: - @NODE_ENV=test vows --spec \ - $(TESTFLAGS) \ - $(TESTS) - -test-cov: - @TESTFLAGS=--cover-plain $(MAKE) test - -.PHONY: test diff --git a/lib/graph.js b/lib/graph.js index d17bee4..11d9f95 100644 --- a/lib/graph.js +++ b/lib/graph.js @@ -3,25 +3,25 @@ */ var request = require('request') - , qs = require('qs') - , url = require('url') - , crypto = require('crypto') - , noop = function(){}; + , qs = require('qs') + , url = require('url') + , crypto = require('crypto') + , noop = function(){}; // Using `extend` from https://github.com/Raynos/xtend function extend(target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i] - , keys = Object.keys(source); - - for (var j = 0; j < keys.length; j++) { - var name = keys[j]; - target[name] = source[name]; + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] + , keys = Object.keys(source); + + for (var j = 0; j < keys.length; j++) { + var name = keys[j]; + target[name] = source[name]; + } } - } - return target; + return target; } @@ -30,12 +30,12 @@ function extend(target) { */ var accessToken = null - , appSecret = null - , graphUrl = 'https://graph.facebook.com' - , graphVersion = '2.9' // default to the oldest version - , oauthDialogUrl = "https://www.facebook.com/v2.0/dialog/oauth?" // oldest version for auth - , oauthDialogUrlMobile = "https://m.facebook.com/v2.0/dialog/oauth?" // oldest version for auth - , requestOptions = {}; + , appSecret = null + , graphUrl = 'https://graph.facebook.com' + , graphVersion = '2.9' // default to the oldest version + , oauthDialogUrl = "https://www.facebook.com/v2.0/dialog/oauth?" // oldest version for auth + , oauthDialogUrlMobile = "https://m.facebook.com/v2.0/dialog/oauth?" // oldest version for auth + , requestOptions = {}; /** * Library version @@ -55,26 +55,26 @@ exports.version = '1.3.0'; */ function Graph(method, url, postData, callback) { - if (typeof callback === 'undefined') { - callback = postData; - postData = {}; - } + if (typeof callback === 'undefined') { + callback = postData; + postData = {}; + } - url = this.prepareUrl(url); - this.callback = callback || noop; - this.postData = postData; + url = this.prepareUrl(url); + this.callback = callback || noop; + this.postData = postData; - this.options = extend({}, requestOptions); - this.options.encoding = this.options.encoding || 'utf-8'; + this.options = extend({}, requestOptions); + this.options.encoding = this.options.encoding || 'utf-8'; - // these particular set of options should be immutable - this.options.method = method; - this.options.uri = url; - this.options.followRedirect = false; + // these particular set of options should be immutable + this.options.method = method; + this.options.uri = url; + this.options.followRedirect = false; - this.request = this[method.toLowerCase()](); + this.request = this[method.toLowerCase()](); - return this; + return this; } @@ -84,13 +84,13 @@ function Graph(method, url, postData, callback) { * @param {string} url string */ Graph.prototype.prepareUrl = function(url) { - url = this.cleanUrl(url); + url = this.cleanUrl(url); - if (url.substr(0,4) !== 'http') { - url = graphUrl + '/v' + graphVersion + url; - } + if (url.substr(0,4) !== 'http') { + url = graphUrl + '/v' + graphVersion + url; + } - return url; + return url; }; /** @@ -102,32 +102,32 @@ Graph.prototype.prepareUrl = function(url) { */ Graph.prototype.cleanUrl = function(url) { - url = url.trim(); + url = url.trim(); - // prep access token in url for appsecret proofing - var regex = /access_token=([^&]*)/; - var results = regex.exec(url); - var sessionAccessToken = results ? results[1] : accessToken; + // prep access token in url for appsecret proofing + var regex = /access_token=([^&]*)/; + var results = regex.exec(url); + var sessionAccessToken = results ? results[1] : accessToken; - // add leading slash - if (url.charAt(0) !== '/' && url.substr(0,4) !== 'http') url = '/' + url; + // add leading slash + if (url.charAt(0) !== '/' && url.substr(0,4) !== 'http') url = '/' + url; - // add access token to url - if (accessToken && url.indexOf('access_token=') === -1) { - url += ~url.indexOf('?') ? '&' : '?'; - url += "access_token=" + accessToken; - } + // add access token to url + if (accessToken && url.indexOf('access_token=') === -1) { + url += ~url.indexOf('?') ? '&' : '?'; + url += "access_token=" + accessToken; + } - // add appsecret_proof to the url - if (sessionAccessToken && appSecret && url.indexOf('appsecret_proof') === -1) { - var hmac = crypto.createHmac('sha256', appSecret); - hmac.update(sessionAccessToken); + // add appsecret_proof to the url + if (sessionAccessToken && appSecret && url.indexOf('appsecret_proof') === -1) { + var hmac = crypto.createHmac('sha256', appSecret); + hmac.update(sessionAccessToken); - url += ~url.indexOf('?') ? '&' : '?'; - url += "appsecret_proof=" + hmac.digest('hex'); - } + url += ~url.indexOf('?') ? '&' : '?'; + url += "appsecret_proof=" + hmac.digest('hex'); + } - return url; + return url; }; /** @@ -136,38 +136,38 @@ Graph.prototype.cleanUrl = function(url) { */ Graph.prototype.end = function (body) { - var json = typeof body === 'string' ? null : body - , err = null; - - if (!json) { - try { - - // this accounts for `real` json strings - if (~body.indexOf('{') && ~body.indexOf('}')) { - json = JSON.parse(body); - - } else { - // this accounts for responses that are plain strings - // access token responses have format of "accessToken=....&..." - // but facebook has random responses that just return "true" - // so we'll convert those to { data: true } - if (!~body.indexOf('=')) body = 'data=' + body; - if (body.charAt(0) !== '?') body = '?' + body; - - json = url.parse(body, true).query; - } - - } catch (e) { - err = { - message: 'Error parsing json' - , exception: e - }; + var json = typeof body === 'string' ? null : body + , err = null; + + if (!json) { + try { + + // this accounts for `real` json strings + if (~body.indexOf('{') && ~body.indexOf('}')) { + json = JSON.parse(body); + + } else { + // this accounts for responses that are plain strings + // access token responses have format of "accessToken=....&..." + // but facebook has random responses that just return "true" + // so we'll convert those to { data: true } + if (!~body.indexOf('=')) body = 'data=' + body; + if (body.charAt(0) !== '?') body = '?' + body; + + json = url.parse(body, true).query; + } + + } catch (e) { + err = { + message: 'Error parsing json' + , exception: e + }; + } } - } - if (!err && (json && json.error)) err = json.error; + if (!err && (json && json.error)) err = json.error; - this.callback(err, json); + this.callback(err, json); }; @@ -176,37 +176,38 @@ Graph.prototype.end = function (body) { */ Graph.prototype.get = function () { - var self = this; - let callbackCalled = false; - - return request.get(this.options, (err, res, body) => { - if (err) { - handleRequestError( - self, - callbackCalled, - { message: 'Error processing https request in get', exception: err }, - ); - callbackCalled = true; - return; - } - - body = { ...JSON.parse(body), headers: res.headers }; - if (~res.headers['content-type'].indexOf('image')) { - body = { - image: true - , headers: res.headers - }; - } - - self.end(body); - }).on('error', (err) => { - handleRequestError( - self, - callbackCalled, - { message: 'Error processing https request in get', exception: err }, - ); - callbackCalled = true; - }); + var self = this; + let callbackCalled = false; + + return request.get(this.options, (err, res, body) => { + if (err) { + handleRequestError( + self, + callbackCalled, + { message: 'Error processing https request in get', exception: err }, + ); + callbackCalled = true; + return; + } + + wrapCallbackWithHeaders(self, res); + + if (res.headers['content-type'] && ~res.headers['content-type'].indexOf('image')) { + return self.end({ image: true, headers: res.headers }); + } + + body = parseAndValidateJSON(self, body, callbackCalled); + if (body === null) return; + + self.end(body); + }).on('error', (err) => { + handleRequestError( + self, + callbackCalled, + { message: 'Error processing https request in get', exception: err }, + ); + callbackCalled = true; + }); }; @@ -216,34 +217,38 @@ Graph.prototype.get = function () { Graph.prototype.post = function() { - var self = this - , postData = qs.stringify(this.postData); - - this.options.body = postData; - let callbackCalled = false; - - return request(this.options, (err, res, body) => { - if (err) { - handleRequestError( - self, - callbackCalled, - { message: 'Error processing https request in post', exception: err }, - ); - callbackCalled = true; - return; - } - - body = { ...JSON.parse(body), headers: res.headers }; - self.end(body); - }) - .on('error', (err) => { - handleRequestError( - self, - callbackCalled, - { message: 'Error processing https request in post', exception: err }, - ); - callbackCalled = true; - }); + var self = this + , postData = qs.stringify(this.postData); + + this.options.body = postData; + let callbackCalled = false; + + return request(this.options, (err, res, body) => { + if (err) { + handleRequestError( + self, + callbackCalled, + { message: 'Error processing https request in post', exception: err }, + ); + callbackCalled = true; + return; + } + + wrapCallbackWithHeaders(self, res); + + body = parseAndValidateJSON(self, body, callbackCalled); + if (body === null) return; + + self.end(body); + }) + .on('error', (err) => { + handleRequestError( + self, + callbackCalled, + { message: 'Error processing https request in post', exception: err }, + ); + callbackCalled = true; + }); }; @@ -254,11 +259,61 @@ Graph.prototype.post = function() { * @param {object} error - the error object */ function handleRequestError(self, callbackCalled, error) { - if (!callbackCalled) { - self.callback(error); - } + if (!callbackCalled) { + self.callback(error); + } +} + +/** + * Wraps the graph callback to attach response headers + * @param {object} self - the graph object + * @param {object} res - the request response object + */ +function wrapCallbackWithHeaders(self, res) { + var originalCallback = self.callback; + self.callback = function(err, data) { + if (err && typeof err === 'object') { + err.headers = res.headers; + } + if (data && typeof data === 'object') { + data.headers = res.headers; + } + originalCallback.call(self, err, data); + }; } +/** + * Parses and validates the response body + * @param {object} self - the graph object + * @param {string} body - the response body + * @param {boolean} callbackCalled - whether the callback has been called + * @returns {object|null} the parsed body or null if an error occurred + */ +function parseAndValidateJSON(self, body, callbackCalled) { + if (typeof body === 'string' && (body.charAt(0) === '{' || body.charAt(0) === '[')) { + try { + return JSON.parse(body); + } catch (parseError) { + handleRequestError( + self, + callbackCalled, + { message: 'Error parsing JSON response', exception: parseError, body: body }, + ); + return null; + } + } + return body; +} + +/** + * Internal helpers exported for testing + */ +exports.__test__ = { + handleRequestError: handleRequestError, + wrapCallbackWithHeaders: wrapCallbackWithHeaders, + parseAndValidateJSON: parseAndValidateJSON +}; + /** * Accepts an url an returns facebook * json data to the callback provided @@ -295,21 +350,21 @@ function handleRequestError(self, callbackCalled, error) { */ exports.get = function(url, params, callback) { - if (typeof params === 'function') { - callback = params; - params = null; - } + if (typeof params === 'function') { + callback = params; + params = null; + } - if (typeof url !== 'string') { - return callback({ message: 'Graph api url must be a string' }, null); - } + if (typeof url !== 'string') { + return callback({ message: 'Graph api url must be a string' }, null); + } - if (params) { - url += ~url.indexOf('?') ? '&' : '?'; - url += qs.stringify(params); - } + if (params) { + url += ~url.indexOf('?') ? '&' : '?'; + url += qs.stringify(params); + } - return new Graph('GET', url, callback); + return new Graph('GET', url, callback); }; /** @@ -326,16 +381,16 @@ exports.get = function(url, params, callback) { */ exports.post = function (url, postData, callback) { - if (typeof url !== 'string') { - return callback({ message: 'Graph api url must be a string' }, null); - } + if (typeof url !== 'string') { + return callback({ message: 'Graph api url must be a string' }, null); + } - if (typeof postData === 'function') { - callback = postData; - postData = url.indexOf('access_token') !== -1 ? {} : {access_token: accessToken}; - } + if (typeof postData === 'function') { + callback = postData; + postData = url.indexOf('access_token') !== -1 ? {} : {access_token: accessToken}; + } - return new Graph('POST', url, postData, callback); + return new Graph('POST', url, postData, callback); }; /** @@ -349,17 +404,17 @@ exports.post = function (url, postData, callback) { */ exports.del = function (url, postData, callback) { - if (!url.match(/[?|&]method=delete/i)) { - url += ~url.indexOf('?') ? '&' : '?'; - url += 'method=delete'; - } + if (!url.match(/[?|&]method=delete/i)) { + url += ~url.indexOf('?') ? '&' : '?'; + url += 'method=delete'; + } - if (typeof postData === 'function') { - callback = postData; - postData = url.indexOf('access_token') !== -1 ? {} : {access_token: accessToken}; - } + if (typeof postData === 'function') { + callback = postData; + postData = url.indexOf('access_token') !== -1 ? {} : {access_token: accessToken}; + } - return this.post(url, postData, callback); + return this.post(url, postData, callback); }; @@ -371,9 +426,9 @@ exports.del = function (url, postData, callback) { */ exports.search = function (options, callback) { - options = options || {}; - var url = '/search?' + qs.stringify(options); - return this.get(url, callback); + options = options || {}; + var url = '/search?' + qs.stringify(options); + return this.get(url, callback); }; /** @@ -387,19 +442,19 @@ exports.search = function (options, callback) { */ exports.batch = function (reqs, additionalData, callback) { - if (!(reqs instanceof Array)) { - return callback({ message: 'Graph api batch requests must be an array' }, null); - } - - if (typeof additionalData === 'function') { - callback = additionalData; - additionalData = {}; - } - - return new Graph('POST', '', extend({}, { - access_token: accessToken, - batch: JSON.stringify(reqs) - }, additionalData), callback); + if (!(reqs instanceof Array)) { + return callback({ message: 'Graph api batch requests must be an array' }, null); + } + + if (typeof additionalData === 'function') { + callback = additionalData; + additionalData = {}; + } + + return new Graph('POST', '', extend({}, { + access_token: accessToken, + batch: JSON.stringify(reqs) + }, additionalData), callback); }; @@ -418,17 +473,17 @@ exports.batch = function (reqs, additionalData, callback) { * @param {function} callback */ exports.fql = function (query, params, callback) { - if (typeof query !== 'string') query = JSON.stringify(query); + if (typeof query !== 'string') query = JSON.stringify(query); - var url = '/fql?q=' + encodeURIComponent(query); + var url = '/fql?q=' + encodeURIComponent(query); - if (typeof params === 'function') { - callback = params; - params = null; - return this.get(url, callback); - } else { - return this.get(url, params, callback); - } + if (typeof params === 'function') { + callback = params; + params = null; + return this.get(url, callback); + } else { + return this.get(url, params, callback); + } }; @@ -440,8 +495,8 @@ exports.fql = function (query, params, callback) { * @returns the oAuthDialogUrl based on params */ exports.getOauthUrl = function (params, opts) { - var url = (opts && opts.mobile) ? oauthDialogUrlMobile : oauthDialogUrl; - return url + qs.stringify(params); + var url = (opts && opts.mobile) ? oauthDialogUrlMobile : oauthDialogUrl; + return url + qs.stringify(params); }; /** @@ -457,13 +512,13 @@ exports.getOauthUrl = function (params, opts) { */ exports.authorize = function (params, callback) { - var self = this; + var self = this; - return this.get("/oauth/access_token", params, function(err, res) { - if (!err) self.setAccessToken(res.access_token); + return this.get("/oauth/access_token", params, function(err, res) { + if (!err) self.setAccessToken(res.access_token); - callback(err, res); - }); + callback(err, res); + }); }; /** @@ -477,18 +532,18 @@ exports.authorize = function (params, callback) { */ exports.extendAccessToken = function (params, callback) { - var self = this; + var self = this; - params.grant_type = 'fb_exchange_token'; - params.fb_exchange_token = params.access_token ? params.access_token : this.getAccessToken(); + params.grant_type = 'fb_exchange_token'; + params.fb_exchange_token = params.access_token ? params.access_token : this.getAccessToken(); - return this.get("/oauth/access_token", params, function(err, res) { - if (!err && !params.access_token) { - self.setAccessToken(res.access_token); - } + return this.get("/oauth/access_token", params, function(err, res) { + if (!err && !params.access_token) { + self.setAccessToken(res.access_token); + } - callback(err, res); - }); + callback(err, res); + }); }; /** @@ -499,9 +554,9 @@ exports.extendAccessToken = function (params, callback) { */ exports.setOptions = function (options) { - if (typeof options === 'object') requestOptions = options; + if (typeof options === 'object') requestOptions = options; - return this; + return this; }; /** @@ -509,7 +564,7 @@ exports.setOptions = function (options) { */ exports.getOptions = function() { - return requestOptions; + return requestOptions; }; /** @@ -518,8 +573,8 @@ exports.getOptions = function() { */ exports.setAccessToken = function(token) { - accessToken = token; - return this; + accessToken = token; + return this; }; /** @@ -527,7 +582,7 @@ exports.setAccessToken = function(token) { */ exports.getAccessToken = function () { - return accessToken; + return accessToken; }; /** @@ -537,14 +592,14 @@ exports.getAccessToken = function () { * @param {string} version */ exports.setVersion = function (version) { - // set version - graphVersion = version; + // set version + graphVersion = version; - // update auth urls - oauthDialogUrl = "https://www.facebook.com/v"+version+"/dialog/oauth?"; // oldest version for auth - oauthDialogUrlMobile = "https://m.facebook.com/v"+version+"/dialog/oauth?"; // oldest version for auth + // update auth urls + oauthDialogUrl = "https://www.facebook.com/v"+version+"/dialog/oauth?"; // oldest version for auth + oauthDialogUrlMobile = "https://m.facebook.com/v"+version+"/dialog/oauth?"; // oldest version for auth - return this; + return this; }; @@ -554,8 +609,8 @@ exports.setVersion = function (version) { */ exports.setAppSecret = function(token) { - appSecret = token; - return this; + appSecret = token; + return this; }; /** @@ -563,7 +618,7 @@ exports.setAppSecret = function(token) { */ exports.getAppSecret = function () { - return appSecret; + return appSecret; }; /** @@ -571,8 +626,8 @@ exports.getAppSecret = function () { */ exports.setGraphUrl = function (url) { - graphUrl = url; - return this; + graphUrl = url; + return this; }; /** @@ -580,5 +635,5 @@ exports.setGraphUrl = function (url) { */ exports.getGraphUrl = function() { - return graphUrl; + return graphUrl; }; diff --git a/package.json b/package.json index e0ee4e9..5c6872a 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,39 @@ { - "name": "fbgraph", - "version": "1.4.4", - "description": "Facebook Graph API client", - "license": "MIT", - "keywords": [ - "facebook", - "api", - "graph" - ], - "author": { - "name": "Cristiano Oliveira", - "email": "ocean.cris@gmail.com>" - }, - "main": "index", - "dependencies": { - "qs": "^6.5.0", - "request": "^2.79.0" - }, - "devDependencies": { - "vows": "^0.7.0" - }, - "repository": { - "type": "git", - "url": "git://github.com/criso/fbgraph.git" - }, - "engines": { - "node": ">= 0.4.1" - } -} + "name": "fbgraph", + "version": "1.4.4", + "description": "Facebook Graph API client", + "license": "MIT", + "keywords": [ + "facebook", + "api", + "graph" + ], + "author": { + "name": "Cristiano Oliveira", + "email": "ocean.cris@gmail.com" + }, + "main": "index", + "scripts": { + "test": "jest" + }, + "jest": { + "testMatch": [ + "**/tests/*.jest.test.js" + ] + }, + "dependencies": { + "qs": "^6.5.0", + "request": "^2.79.0" + }, + "devDependencies": { + "jest": "^29.7.0" + }, + "repository": { + "type": "git", + "url": "git://github.com/criso/fbgraph.git" + }, + "engines": { + "node": ">= 14" + } + } + diff --git a/tests/graph.jest.test.js b/tests/graph.jest.test.js new file mode 100644 index 0000000..61d5c53 --- /dev/null +++ b/tests/graph.jest.test.js @@ -0,0 +1,213 @@ + +const request = require("request"); +// Mock must happen before requiring graph/index +jest.mock("request", () => { + const originalModule = jest.requireActual("request"); + const mock = jest.fn((options, callback) => { + return originalModule(options, callback); + }); + mock.get = jest.fn((options, callback) => { + return originalModule.get(options, callback); + }); + return mock; +}); + +const graph = require("../index"); +const FBConfig = require("./config").facebook; + +describe("graph.test", () => { + let testUser1 = {}; + const appAccessToken = FBConfig.appId + "|" + FBConfig.appSecret; + const testUserParams = { + installed: true, + name: "Ricky Bobby", + permissions: FBConfig.scope, + method: "post", + access_token: appAccessToken + }; + + beforeAll(() => { + graph.setAccessToken(null); + }); + + describe("Before starting a test suite", () => { + test("*Access Token* should be null", () => { + expect(graph.getAccessToken()).toBeNull(); + }); + + test("should be able to set *request* options", () => { + const options = { + timeout: 30000, + pool: false, + headers: { connection: "keep-alive" } + }; + + graph.setOptions(options); + expect(graph.getOptions()).toEqual(options); + + // reset + graph.setOptions({}); + }); + }); + + describe("When accessing the graphApi with no *Access Token*", () => { + test("and searching for public data via username", (done) => { + graph.get("/btaylor", (err, res) => { + if (res && res.error) { + expect(res).toHaveProperty("error"); + } else if (res) { + expect(res).toHaveProperty("name"); + expect(res.name).toBe("Bret Taylor"); + } + done(); + }); + }); + + test("and requesting an url for a user that does not exist", (done) => { + graph.get("/thisUserNameShouldNotExist", (err, res) => { + expect(res).toHaveProperty("error"); + done(); + }); + }); + + test("and not using a string as an api url", (done) => { + graph.get({ you: "shall not pass" }, (err, res) => { + expect(err.message).toBe("Graph api url must be a string"); + done(); + }); + }); + + test("and requesting a public profile picture", (done) => { + graph.get("/zuck/picture", (err, res) => { + if (res && res.error) { + expect(res).toHaveProperty("error"); + } else if (res) { + expect(res).toHaveProperty("image"); + expect(res).toHaveProperty("location"); + } + done(); + }); + }); + + test("and requesting an api url with a missing slash", (done) => { + graph.get("zuck/picture", (err, res) => { + if (res && res.error) { + expect(res).toHaveProperty("error"); + } else if (res) { + expect(res).toHaveProperty("image"); + expect(res).toHaveProperty("location"); + } + done(); + }); + }); + + test("and requesting an api url with prefixed graphurl", (done) => { + graph.get(graph.getGraphUrl() + "/zuck/picture", (err, res) => { + if (res && res.error) { + expect(res).toHaveProperty("error"); + } else if (res) { + expect(res).toHaveProperty("image"); + expect(res).toHaveProperty("location"); + } + done(); + }); + }); + + test("and trying to access data that requires an access token", (done) => { + graph.get("/817129783203", (err, res) => { + expect(res).toHaveProperty("error"); + expect(res.error.type).toBe("OAuthException"); + done(); + }); + }); + + test("and performing a public search", (done) => { + graph.search({ q: "watermelon", type: "post" }, (err, res) => { + if (res && res.error) { + expect(res).toHaveProperty("error"); + } else if (res) { + expect(res).not.toBeNull(); + expect(Array.isArray(res.data)).toBe(true); + } + done(); + }); + }); + }); + + describe("Hardening JSON Parsing", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("from a GET request should return an error instead of crashing", (done) => { + const mockRes = { headers: { 'content-type': 'text/html' } }; + const mockBody = '{ malformed json'; + + request.get.mockImplementationOnce((options, cb) => { + setImmediate(() => cb(null, mockRes, mockBody)); + return { on: jest.fn().mockReturnThis() }; + }); + + graph.get('/me', (err, res) => { + expect(err).not.toBeNull(); + expect(err.message).toBe('Error parsing JSON response'); + expect(err.body).toBe(mockBody); + expect(err.headers).toEqual(mockRes.headers); + done(); + }); + }); + + test("from a GET request with non-JSON string should handle it gracefully", (done) => { + const mockRes = { headers: { 'content-type': 'text/html' } }; + const mockBody = 'HTML Response'; + + request.get.mockImplementationOnce((options, cb) => { + setImmediate(() => cb(null, mockRes, mockBody)); + return { on: jest.fn().mockReturnThis() }; + }); + + graph.get('/me', (err, res) => { + expect(res).toBeDefined(); + expect(res.data).toContain(''); + expect(res.headers).toEqual(mockRes.headers); + done(); + }); + }); + + test("from a POST request should return an error instead of crashing", (done) => { + const mockRes = { headers: { 'content-type': 'text/html' } }; + const mockBody = '{ malformed json'; + + request.mockImplementationOnce((options, cb) => { + setImmediate(() => cb(null, mockRes, mockBody)); + return { on: jest.fn().mockReturnThis() }; + }); + + graph.post('/me', { msg: 'hi' }, (err, res) => { + expect(err).not.toBeNull(); + expect(err.message).toBe('Error parsing JSON response'); + expect(err.body).toBe(mockBody); + expect(err.headers).toEqual(mockRes.headers); + done(); + }); + }); + + test("from a DELETE request should return an error instead of crashing", (done) => { + const mockRes = { headers: { 'content-type': 'text/html' } }; + const mockBody = '{ malformed json'; + + request.mockImplementationOnce((options, cb) => { + setImmediate(() => cb(null, mockRes, mockBody)); + return { on: jest.fn().mockReturnThis() }; + }); + + graph.del('/me', (err, res) => { + expect(err).not.toBeNull(); + expect(err.message).toBe('Error parsing JSON response'); + expect(err.body).toBe(mockBody); + expect(err.headers).toEqual(mockRes.headers); + done(); + }); + }); + }); +}); diff --git a/tests/graph.test.js b/tests/graph.test.js deleted file mode 100644 index c782f52..0000000 --- a/tests/graph.test.js +++ /dev/null @@ -1,290 +0,0 @@ -var graph = require("../index") - , FBConfig = require("./config").facebook - , vows = require("vows") - , events = require("events") - , assert = require("assert"); - - -var testUser1 = {} - , appAccessToken = FBConfig.appId + "|" + FBConfig.appSecret - , testUserParams = { - installed: true - , name: "Ricky Bobby" - , permissions: FBConfig.scope - , method: "post" - , access_token: appAccessToken - }; - - -vows.describe("graph.test").addBatch({ - "Before starting a test suite": { - topic: function () { - return graph.setAccessToken(null); - }, - - "*Access Token* should be null": function (graph) { - assert.isNull(graph.getAccessToken()); - }, - - "should be able to set *request* options": function (graph) { - var options = { - timeout: 30000 - , pool: false - , headers: { connection: "keep-alive" } - }; - - graph.setOptions(options); - assert.equal(graph.getOptions(), options); - - // reset - graph.setOptions({}); - } - } -}).addBatch({ - "When accessing the graphApi": { - "with no *Access Token** ": { - "and searching for public data via username": { - topic: function() { - graph.get("/btaylor", this.callback); - }, - - "should get public data": function (err, res) { - assert.include(res, "username"); - assert.include(res, "name"); - assert.include(res, "first_name"); - assert.include(res, "last_name"); - } - }, - - "and requesting an url for a user that does not exist": { - topic: function () { - graph.get("/thisUserNameShouldNotExist", this.callback); - }, - - "should return an error": function (err, res) { - assert.include(res, "error"); - } - }, - - "and not using a string as an api url": { - topic: function () { - graph.get({ you: "shall not pass" }, this.callback); - }, - - "should return an api must be a string error": function (err, res) { - assert.equal(err.message, "Graph api url must be a string", - "Should return an error if api url is not a string"); - } - }, - - "and requesting a public profile picture": { - topic: function () { - graph.get("/zuck/picture", this.callback); - }, - - "should get an image and return a json with its location ": function (err, res) { - assert.include(res, "image"); - assert.include(res, "location"); - } - }, - - "and requesting an api url with a missing slash": { - topic: function () { - graph.get("zuck/picture", this.callback); - }, - - "should be able to get valid data": function (err, res) { - assert.include(res, "image"); - assert.include(res, "location"); - } - }, - - "and requesting an api url with prefixed graphurl": { - topic: function() { - graph.get(graph.getGraphUrl() + "/zuck/picture", this.callback); - }, - - "should be able to get valid data": function (err, res) { - assert.include(res, "image"); - assert.include(res, "location"); - } - }, - - "and trying to access data that requires an access token": { - topic: function () { - graph.get("/817129783203", this.callback); - }, - - "should return an OAuthException error": function (err, res) { - assert.include(res, "error"); - assert.equal(res.error.type, "OAuthException", - "Response from facebook should be an OAuthException"); - } - }, - - "and performing a public search ": { - topic: function () { - graph.search({ q: "watermelon", type: "post" }, this.callback); - }, - - "should return valid data": function (err, res) { - assert.isNotNull(res); - assert.isArray(res.data); - assert.ok(res.data.length > 1, "response data should not be empty"); - } - }, - - }, - - "with an *Access Token* ": { - topic: function () { - var promise = new events.EventEmitter(); - - // create test user - var testUserUrl = FBConfig.appId + "/accounts/test-users"; - - graph.get(testUserUrl, testUserParams, function(err, res) { - - if (!res || res.error - && ~res.error.message.indexOf("Service temporarily unavailable")) { - - promise.emit("error", err); - console.error("Can't retreive access token from facebook\n" + - "Try again in a few minutes"); - } else { - - graph.setAccessToken(res.access_token); - testUser1 = res; - promise.emit("success", res); - } - }); - - return promise; - }, - - // following tests will only happen after - // an access token has been set - "result *keys* should be valid": function(err, res) { - assert.isNull(err); - assert.include(res, "id"); - assert.include(res, "access_token"); - assert.include(res, "login_url"); - assert.include(res, "email"); - assert.include(res, "password"); - }, - - "and getting data from a protected page": { - topic: function () { - graph.get("/817129783203", this.callback); - }, - - "response should be valid": function(err, res) { - assert.isNull(err); - assert.equal("817129783203", res.id, "response id should be valid"); - } - }, - - "and getting a user permissions": { - topic: function () { - graph.get("/me/permissions", this.callback); - }, - - "test user should have proper permissions": function (err, res) { - assert.isNull(err); - - var permissions = FBConfig.scope - .replace(/ /g,"") - .split(","); - - permissions.push("installed"); - - permissions.forEach(function(key) { - assert.include(res.data[0], key); - }); - } - }, - - "and performing a search": { - topic: function () { - var searchOptions = { - q: "coffee" - , type: "place" - , center: "37.76,-122.427" - , distance: 1000 - }; - - graph.search(searchOptions, this.callback); - }, - - "an *Access Token* required search should return valid data": function (err, res) { - assert.isNull(err); - assert.ok(res.data.length > 1, "response data should not be empty"); - } - }, - - "and requesting a FQL query": { - topic: function () { - var query = "SELECT name FROM user WHERE uid = me()"; - - graph.fql(query, this.callback); - }, - - "should return valid data": function (err, res) { - assert.isNull(err); - assert.include(res, 'data'); - assert.isArray(res.data); - assert.equal(res.data[0].name, testUserParams.name); - } - }, - - "and requesting a FQL multi-query": { - topic: function () { - var query = { - name: "SELECT name FROM user WHERE uid = me()" - , permissions: "SELECT " + FBConfig.scope + " FROM permissions WHERE uid = me()" - }; - - graph.fql(query, this.callback); - }, - - "should return valid data": function (err, res) { - assert.isNull(err); - assert.include(res, 'data'); - assert.isArray(res.data); - - var nameQuery = {} - , permsQuery = {}; - - if (res.data[0].name === 'name') { - nameQuery = res.data[0]; - permsQuery = res.data[1]; - } else { - permsQuery = res.data[0]; - nameQuery = res.data[1]; - } - - assert.isArray(nameQuery.fql_result_set); - assert.isArray(permsQuery.fql_result_set); - assert.equal(nameQuery.fql_result_set[0].name, testUserParams.name); - - console.dir(permsQuery.fql_result_set); - var permissions = permsQuery.fql_result_set[0]; - - testUserParams.permissions.split(', ').forEach(function(permission) { - assert.include(permissions, permission); - }); - } - } - } - } -}).addBatch({ - "When tests are over": { - topic: function () { - graph.del(testUser1.id, this.callback); - }, - - "test users should be removed": function(res){ - assert.equal(res.data, "true"); - } - } -}).export(module); diff --git a/tests/helpers.jest.test.js b/tests/helpers.jest.test.js new file mode 100644 index 0000000..96f4750 --- /dev/null +++ b/tests/helpers.jest.test.js @@ -0,0 +1,153 @@ + +const graph = require('../index'); + +describe('Internal Helper Methods', () => { + describe('wrapCallbackWithHeaders', () => { + test('should attach headers to response data object', (done) => { + const mockHeaders = { 'x-fb-debug': '12345' }; + const mockRes = { headers: mockHeaders }; + const originalData = { id: 'me' }; + + const self = { + callback: (err, data) => { + try { + expect(err).toBeNull(); + expect(data).toEqual({ + id: 'me', + headers: mockHeaders + }); + done(); + } catch (e) { + done(e); + } + } + }; + + graph.__test__.wrapCallbackWithHeaders(self, mockRes); + + // Trigger the wrapped callback + self.callback(null, originalData); + }); + + test('should attach headers to error object if it exists', (done) => { + const mockHeaders = { 'x-fb-error': 'true' }; + const mockRes = { headers: mockHeaders }; + const mockError = { message: 'OAuth Error' }; + + const self = { + callback: (err, data) => { + try { + expect(err.message).toBe('OAuth Error'); + expect(err.headers).toEqual(mockHeaders); + done(); + } catch (e) { + done(e); + } + } + }; + + graph.__test__.wrapCallbackWithHeaders(self, mockRes); + + // Trigger with error + self.callback(mockError, null); + }); + + test('should preserve "this" binding of the original callback', (done) => { + const mockRes = { headers: {} }; + const self = { + someValue: 'preserved' + }; + + self.callback = function(err, data) { + try { + expect(this.someValue).toBe('preserved'); + done(); + } catch (e) { + done(e); + } + }; + + graph.__test__.wrapCallbackWithHeaders(self, mockRes); + + // Trigger the wrapped callback + self.callback(null, {}); + }); + }); + + describe('handleRequestError', () => { + test('should call callback if it has not been called', (done) => { + const mockError = { message: 'test error' }; + const self = { + callback: (err) => { + try { + expect(err).toEqual(mockError); + done(); + } catch (e) { + done(e); + } + } + }; + + graph.__test__.handleRequestError(self, false, mockError); + }); + + test('should not call callback if it has already been called', () => { + const mockError = { message: 'test error' }; + const callback = jest.fn(); + const self = { callback }; + + graph.__test__.handleRequestError(self, true, mockError); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('parseAndValidateJSON', () => { + test('should parse valid JSON object', () => { + const body = '{"id":"me"}'; + const result = graph.__test__.parseAndValidateJSON({}, body, false); + expect(result).toEqual({ id: 'me' }); + }); + + test('should parse valid JSON array', () => { + const body = '[{"id":"me"}]'; + const result = graph.__test__.parseAndValidateJSON({}, body, false); + expect(result).toEqual([{ id: 'me' }]); + }); + + test('should return null and call handleRequestError for malformed JSON', (done) => { + const body = '{ malformed'; + const self = { + callback: (err) => { + try { + expect(err.message).toBe('Error parsing JSON response'); + expect(err.body).toBe(body); + done(); + } catch (e) { + done(e); + } + } + }; + + const result = graph.__test__.parseAndValidateJSON(self, body, false); + expect(result).toBeNull(); + }); + + test('should return raw body for non-JSON strings (like HTML)', () => { + const body = ''; + const result = graph.__test__.parseAndValidateJSON({}, body, false); + expect(result).toBe(body); + }); + + test('should return raw body for non-JSON strings (like query strings)', () => { + const body = 'access_token=123&expires=456'; + const result = graph.__test__.parseAndValidateJSON({}, body, false); + expect(result).toBe(body); + }); + + test('should return raw body for boolean-like strings', () => { + const body = 'true'; + const result = graph.__test__.parseAndValidateJSON({}, body, false); + expect(result).toBe(body); + }); + }); +}); diff --git a/tests/testUsers.jest.test.js b/tests/testUsers.jest.test.js new file mode 100644 index 0000000..1f093aa --- /dev/null +++ b/tests/testUsers.jest.test.js @@ -0,0 +1,113 @@ + +const graph = require("../index"); +const FBConfig = require("./config").facebook; + +describe("testUser.test", () => { + let testUser1 = {}; + let testUser2 = {}; + const appAccessToken = FBConfig.appId + "|" + FBConfig.appSecret; + const wallPost = { message: "I'm gonna come at you like a spider monkey, chip" }; + + const hasRealCredentials = FBConfig.appId !== 'YOUR APP ID'; + + beforeAll(() => { + graph.setAccessToken(null); + }); + + test("*access token* should be null", () => { + expect(graph.getAccessToken()).toBeNull(); + }); + + (hasRealCredentials ? describe : describe.skip)("With test users", () => { + const testUserUrl = FBConfig.appId + "/accounts/test-users"; + + test("we should be able to create users, friend them, and post to wall", (done) => { + const params1 = { + installed: true, + name: "Rocket Man", + permissions: FBConfig.scope, + method: "post", + access_token: appAccessToken + }; + + // Step 1: Create user 1 + graph.get(testUserUrl, params1, (err, res1) => { + if (err || (res1 && res1.error)) { + expect(err || res1.error).toBeDefined(); + done(); + return; + } + + testUser1 = res1; + expect(res1).not.toBeNull(); + + // Step 2: Create user 2 + const params2 = { + installed: true, + name: "Magic Man", + permissions: FBConfig.scope, + method: "post", + access_token: appAccessToken + }; + + graph.get(testUserUrl, params2, (err, res2) => { + testUser2 = res2; + expect(res2).not.toBeNull(); + + // Step 3: Friend request from user1 to user2 + const friendUrl1 = testUser1.id + "/friends/" + testUser2.id + "?method=post"; + graph.setAccessToken(testUser1.access_token); + + graph.get(encodeURI(friendUrl1), (err, res3) => { + expect(res3).not.toBeNull(); + + // Step 4: Accept friend request from user2 + const friendUrl2 = testUser2.id + "/friends/" + testUser1.id + "?method=post"; + graph.setAccessToken(testUser2.access_token); + + graph.get(encodeURI(friendUrl2), (err, res4) => { + if (res4 && !res4.error) { + expect(res4.data).toBe("true"); + } + + // Step 5: Post on wall + graph.setAccessToken(testUser1.access_token); + graph.post(testUser2.id + "/feed", wallPost, (err, res5) => { + if (res5 && !res5.error) { + expect(res5).toHaveProperty('id'); + + // Step 6: Query the post + graph.get(res5.id, (err, res6) => { + expect(res6).not.toBeNull(); + expect(res6.message).toBe(wallPost.message); + expect(res6.from.id).toBe(testUser1.id); + done(); + }); + } else { + done(); + } + }); + }); + }); + }); + }); + }); + }); + + afterAll((done) => { + if (!hasRealCredentials) { + done(); + return; + } + graph.setAccessToken(appAccessToken); + if (testUser1.id && testUser2.id) { + graph.del(testUser1.id, () => { + graph.del(testUser2.id, () => { + done(); + }); + }); + } else { + done(); + } + }); +}); diff --git a/tests/testUsers.test.js b/tests/testUsers.test.js deleted file mode 100644 index 68676df..0000000 --- a/tests/testUsers.test.js +++ /dev/null @@ -1,153 +0,0 @@ -var graph = require("../index") - , FBConfig = require("./config").facebook - , vows = require("vows") - , assert = require("assert"); - - -var testUser1 = {} - , testUser2 = {} - , appAccessToken = FBConfig.appId + "|" + FBConfig.appSecret - , wallPost = { message: "I'm gonna come at you like a spider monkey, chip" }; - - -vows.describe("testUser.test").addBatch({ - "Before starting a test suite": { - topic: function () { - return graph.setAccessToken(null); - }, - - "*access token* should be null": function (graph) { - assert.isNull(graph.getAccessToken()); - } - } - -}).addBatch({ - "With test users": { - topic: function () { - // create test user - var testUserUrl = FBConfig.appId + "/accounts/test-users"; - var params = { - installed: true - , name: "Rocket Man" - , permissions: FBConfig.scope - , method: "post" - , access_token: appAccessToken - }; - - graph.get(testUserUrl, params, this.callback); - }, - - "we should be able to create *user 1*": function(res) { - assert.isNotNull(res); - }, - - "after creating *user 1*": { - topic: function (res) { - testUser1 = res; - - // create test user - var testUserUrl = FBConfig.appId + "/accounts/test-users"; - var params = { - installed: true - , name: "Magic Man" - , permissions: FBConfig.scope - , method: "post" - , access_token: appAccessToken - }; - - graph.get(testUserUrl, params, this.callback); - }, - - "we should be able to create *user 2*": function(res) { - assert.isNotNull(res); - }, - - "and *user2* ": { - topic: function (res) { - testUser2 = res; - - // The first call should be made with access token of user1 - // This will creates a friend request from user1 to user2 - var apiUrl = testUser1.id + "/friends/" + testUser2.id - + "?method=post"; - - graph.setAccessToken(testUser1.access_token); - graph.get(encodeURI(apiUrl), this.callback); - }, - - "*user1* should send a friend request": function(res) { - assert.isNotNull(res); - }, - - "and after a friend request has been made": { - - topic: function (res) { - var apiUrl = testUser2.id + "/friends/" + testUser1.id - + "?method=post"; - - // The second call should be made with access - // token for user2 and will confirm the request. - graph.setAccessToken(testUser2.access_token); - graph.get(encodeURI(apiUrl), this.callback); - }, - - "*user2* should accept friend request": function (res) { - assert.equal(res.data, "true"); - }, - - " - a post on *user1*'s wall" : { - topic: function() { - graph.setAccessToken(testUser1.access_token); - graph.post(testUser2.id + "/feed", wallPost, this.callback); - }, - - "should have a response with an id": function (res) { - assert.include(res, 'id'); - }, - - "when queried": { - topic: function (res) { - graph.get(res.id, this.callback); - }, - - "should be valid": function (res) { - assert.isNotNull(res); - assert.equal(res.message, wallPost.message); - assert.equal(res.from.id, testUser1.id); - } - } - } - } - } - } - } -}).addBatch({ - - "When tests are over": { - topic: function () { - return graph.setAccessToken(appAccessToken); - }, - - "after reseting the access token - ": { - "test *user 1*": { - topic: function (graph) { - graph.del(testUser1.id, this.callback); - }, - - "should be removed": function(res){ - assert.equal(res.data, "true"); - } - }, - - "test *user 2*": { - topic: function (graph) { - graph.del(testUser2.id, this.callback); - }, - - "should be removed": function(res){ - assert.equal(res.data, "true"); - } - } - } - } -}).export(module);