From 79b7dfe9e45471815a167a0e796c0b4b85665a37 Mon Sep 17 00:00:00 2001 From: Jeff Cuevas-Koch <4649003+cuevaskoch@users.noreply.github.com> Date: Mon, 14 Dec 2020 17:58:56 -0800 Subject: [PATCH] feat(twitter): EN-6897: Add Twitter features. (#88) * Add functionality to save Twitter OAuth tokens. * Add functionality to retrieve connected Twitter usernames. Signed-off-by: Jeff Cuevas-Koch Co-authored-by: Jeff Cuevas-Koch --- .../manage_twitter_authentication.test.js | 52 ++++++++++++++++ src/mocks/index.js | 1 + src/mocks/twitter.js | 25 ++++++++ src/resources/Customer.js | 18 ++++++ src/resources/Customer.test.js | 4 ++ src/resources/TwitterOAuth.js | 59 +++++++++++++++++++ src/resources/TwitterOAuth.test.js | 41 +++++++++++++ src/resources/TwitterUsername.js | 48 +++++++++++++++ src/resources/TwitterUsername.test.js | 40 +++++++++++++ 9 files changed, 288 insertions(+) create mode 100644 src/examples/manage_twitter_authentication.test.js create mode 100644 src/mocks/twitter.js create mode 100644 src/resources/TwitterOAuth.js create mode 100644 src/resources/TwitterOAuth.test.js create mode 100644 src/resources/TwitterUsername.js create mode 100644 src/resources/TwitterUsername.test.js diff --git a/src/examples/manage_twitter_authentication.test.js b/src/examples/manage_twitter_authentication.test.js new file mode 100644 index 0000000..c670ff2 --- /dev/null +++ b/src/examples/manage_twitter_authentication.test.js @@ -0,0 +1,52 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import fetchMock from 'fetch-mock'; +import Track from '../index'; +import { + charlie, + twitter as mockTwitter, +} from '../mocks'; + +chai.should(); +chai.use(chaiAsPromised); + +describe('When saving a twitter oauth token', () => { + const api = new Track({ autoRenew: false }); + + beforeEach(() => charlie.setUpSuccessfulMock(api.client)); + beforeEach(() => mockTwitter.setUpSuccessfulMock(api.client)); + beforeEach(() => fetchMock.catch(503)); + afterEach(fetchMock.restore); + + it('should save a token', () => { + api.logIn({ username: 'charlie@example.com', password: 'securepassword' }); + + const tokenPromise = api.customer('SYNC').twitterOAuth({ + username: 'GMVSyncromatics', + token: 'example_oauth_token', + secret: 'example_oauth_secret', + profile_image_url: 'https://example.com/gmvsyncromatics.png', + }).update().then(success => success); // check success + + return tokenPromise; + }); +}); + +describe('When retrieving the connected twitter username', () => { + const api = new Track({ autoRenew: false }); + + beforeEach(() => charlie.setUpSuccessfulMock(api.client)); + beforeEach(() => mockTwitter.setUpSuccessfulMock(api.client)); + beforeEach(() => fetchMock.catch(503)); + afterEach(fetchMock.restore); + + it('should save a token', () => { + api.logIn({ username: 'charlie@example.com', password: 'securepassword' }); + + const usernamePromise = api.customer('SYNC').twitterUsername() + .fetch() + .then(username => username); // Do things with username + + return usernamePromise; + }); +}); diff --git a/src/mocks/index.js b/src/mocks/index.js index 3bc1860..cce88e6 100644 --- a/src/mocks/index.js +++ b/src/mocks/index.js @@ -27,6 +27,7 @@ export { default as signs } from './signs'; export { default as stops } from './stops'; export { default as tags } from './tags'; export { default as trips } from './trips'; +export { default as twitter } from './twitter'; export { default as users } from './users'; export { default as vehicles } from './vehicles'; export { default as voipTickets } from './voipTicket'; diff --git a/src/mocks/twitter.js b/src/mocks/twitter.js new file mode 100644 index 0000000..7f24f87 --- /dev/null +++ b/src/mocks/twitter.js @@ -0,0 +1,25 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import fetchMock from 'fetch-mock'; +import Client from '../Client'; + +const twitter = { + setUpSuccessfulMock: (client) => { + const oauthUrl = '/1/SYNC/twitter/oauth'; + const oauthMutationResponse = () => new Response(undefined); + + const usernameUrl = '/1/SYNC/twitter/username'; + const usernameResponse = () => new Response(Client.toBlob({ + href: '/1/SYNC/twitter/username', + username: 'GMVSYNC', + is_valid: true, + profile_image_url: 'https://example.com/gmvsync.png', + })); + + fetchMock + .get(client.resolve(usernameUrl), usernameResponse) + .put(client.resolve(oauthUrl), oauthMutationResponse) + .delete(client.resolve(oauthUrl), oauthMutationResponse); + }, +}; + +export default twitter; diff --git a/src/resources/Customer.js b/src/resources/Customer.js index 9f64c0a..0f93e40 100644 --- a/src/resources/Customer.js +++ b/src/resources/Customer.js @@ -39,6 +39,8 @@ import StopsContext from './StopsContext'; import Tag from './Tag'; import TagsContext from './TagsContext'; import Trip from './Trip'; +import TwitterOAuth from './TwitterOAuth'; +import TwitterUsername from './TwitterUsername'; import Vehicle from './Vehicle'; import VehiclesContext from './VehiclesContext'; import VoipTicket from './VoipTicket'; @@ -419,6 +421,22 @@ class Customer extends Resource { return this.resource(Trip, Trip.makeHref(this.code, id)); } + /** + * Gets a TwitterOAuth resource + * @returns {TwitterOAuth} TwitterOAuth resource + */ + twitterOAuth() { + return this.resource(TwitterOAuth, TwitterOAuth.makeHref(this.code)); + } + + /** + * Gets a TwitterUsername resource + * @returns {TwitterUsername} TwitterUsername resource + */ + twitterUsername() { + return this.resource(TwitterUsername, TwitterUsername.makeHref(this.code)); + } + /** * Gets a context for querying users that have access to this customer. * Note that returned users might also have access to other customers, diff --git a/src/resources/Customer.test.js b/src/resources/Customer.test.js index 967266e..c36ecde 100644 --- a/src/resources/Customer.test.js +++ b/src/resources/Customer.test.js @@ -36,6 +36,8 @@ import StopsContext from './StopsContext'; import Tag from './Tag'; import TagsContext from './TagsContext'; import Trip from './Trip'; +import TwitterOAuth from './TwitterOAuth'; +import TwitterUsername from './TwitterUsername'; import Vehicle from './Vehicle'; import VehiclesContext from './VehiclesContext'; import VoipTicket from './VoipTicket'; @@ -82,6 +84,8 @@ describe('When getting resources related to a customer', () => { it('should allow tags to be searched', () => customer.tags().should.be.instanceof(TagsContext)); it('should allow a tag to be retrieved', () => customer.tag().should.be.instanceof(Tag)); it('should allow a trip to be retrieved', () => customer.trip().should.be.instanceof(Trip)); + it('should allow a twitter oauth token to be created', () => customer.twitterOAuth().should.be.instanceof(TwitterOAuth)); + it('should allow a twitter username to be retrieved', () => customer.twitterUsername().should.be.instanceof(TwitterUsername)); it('should allow users to be searched', () => customer.users().should.be.instanceOf(CustomerUsersContext)); it('should allow vehicles to be searched', () => customer.vehicles().should.be.instanceof(VehiclesContext)); it('should allow a vehicle to be retrieved', () => customer.vehicle().should.be.instanceof(Vehicle)); diff --git a/src/resources/TwitterOAuth.js b/src/resources/TwitterOAuth.js new file mode 100644 index 0000000..67a8087 --- /dev/null +++ b/src/resources/TwitterOAuth.js @@ -0,0 +1,59 @@ +import Resource from './Resource'; + +/** + * Write-only Twitter OAuth resource + */ +class TwitterOAuth extends Resource { + /** + * Creates a new TwitterOAuth + * + * Will populate itself with the values given after the client parameter + * @param {Client} client Instance of pre-configured client + * @param {Object} rest The object to use in assigning values to this instance + */ + constructor(client, rest) { + super(client); + const { code, ...newProperties } = rest; + this.customerCode = code; + Object.assign(this, newProperties, { + hydrated: false, + }); + } + + /** + * Makes a href for a given customer code + * @param {string} customerCode Customer code + * @returns {string} URI to instance of TwitterOAuth + */ + static makeHref(customerCode) { + return { + href: `/1/${customerCode}/twitter/oauth`, + code: customerCode, + }; + } + + /** + * Saves data for this Twitter OAuth to the server + * + * Does not return the created object since TwitterOAuth + * is meant to be write-only. + * @returns {Promise} If successful, returns a completed promise. + */ + update() { + const { client, customerCode, hydrated, ...body } = this; + const { href } = TwitterOAuth.makeHref(customerCode); + return client.put(href, { body }).then(() => ({ success: true })); + } + + /** + * Removes stored Twitter OAuth information via the client + * @returns {Promise} If successful, returns a resolved promise + */ + delete() { + const { client, customerCode } = this; + const { href } = TwitterOAuth.makeHref(customerCode); + return client.delete(href).then(() => {}); + } +} + +export default TwitterOAuth; diff --git a/src/resources/TwitterOAuth.test.js b/src/resources/TwitterOAuth.test.js new file mode 100644 index 0000000..d6e5a74 --- /dev/null +++ b/src/resources/TwitterOAuth.test.js @@ -0,0 +1,41 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import fetchMock from 'fetch-mock'; + +import Client from '../Client'; +import TwitterOAuth from './TwitterOAuth'; +import { twitter as mockTwitter } from '../mocks'; + +chai.should(); +chai.use(chaiAsPromised); + +describe('When instantiating a TwitterOAuth based on customer', () => { + const client = new Client(); + const oauth = new TwitterOAuth(client, TwitterOAuth.makeHref('SYNC')); + + it('should set the href', () => oauth.href.should.equal('/1/SYNC/twitter/oauth')); + it('should not be hydrated', () => oauth.hydrated.should.equal(false)); +}); + +describe('When updating an TwitterOAuth information for a customer', () => { + const client = new Client(); + + beforeEach(() => mockTwitter.setUpSuccessfulMock(client)); + beforeEach(() => fetchMock.catch(503)); + afterEach(fetchMock.restore); + + let promise; + beforeEach(() => { + const oauth = new TwitterOAuth(client, { + code: 'SYNC', + username: 'GMVSyncromatics', + token: 'example_oauth_token', + secret: 'example_oauth_secret', + profile_image_url: 'https://example.com/gmvsyncromatics.png', + }); + promise = oauth.update().then(updated => updated); + }); + + it('should resolve the promise', () => promise.should.be.fulfilled); + it('should return success', () => promise.then(v => v.success).should.eventually.equal(true)); +}); diff --git a/src/resources/TwitterUsername.js b/src/resources/TwitterUsername.js new file mode 100644 index 0000000..f52af71 --- /dev/null +++ b/src/resources/TwitterUsername.js @@ -0,0 +1,48 @@ +import Resource from './Resource'; + +/** + * Read-only Twitter Username resource. + */ +class TwitterUsername extends Resource { + /** + * Creates a new TwitterUsername + * + * @param {Client} client Instance of pre-configured client + * @param {Object} rest The object to use in assigning values to this instance + */ + constructor(client, rest) { + super(client); + const { code, ...newProperties } = rest; + this.customerCode = code; + const hydrated = !Object.keys(newProperties).every(k => k === 'href' || k === 'customerCode'); + Object.assign(this, newProperties, { + hydrated, + }); + } + + /** + * Makes a href for a given customer code + * @param {string} customerCode Customer code + * @returns {string} URI to instance of call + */ + static makeHref(customerCode) { + return { + code: customerCode, + href: `/1/${customerCode}/twitter/username`, + }; + } + + /** + * Fetches the data for this TwitterUsername via the client + * @returns {Promise} If successful, a hydrated instance of this TwitterUsername + */ + fetch() { + const { customerCode } = this; + const { href } = TwitterUsername.makeHref(customerCode); + return this.client.get(href) + .then(response => response.json()) + .then(username => new TwitterUsername(this.client, { ...this, ...username })); + } +} + +export default TwitterUsername; diff --git a/src/resources/TwitterUsername.test.js b/src/resources/TwitterUsername.test.js new file mode 100644 index 0000000..669ac6b --- /dev/null +++ b/src/resources/TwitterUsername.test.js @@ -0,0 +1,40 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import fetchMock from 'fetch-mock'; + +import Client from '../Client'; +import TwitterUsername from './TwitterUsername'; +import { twitter as mockTwitter } from '../mocks'; + +chai.should(); +chai.use(chaiAsPromised); + +describe('When instantiating a TwitterUsername based on customer', () => { + const client = new Client(); + const oauth = new TwitterUsername(client, TwitterUsername.makeHref('SYNC')); + + it('should set the href', () => oauth.href.should.equal('/1/SYNC/twitter/username')); + it('should not be hydrated', () => oauth.hydrated.should.equal(false)); +}); + +describe('When fetching a TwitterUsername for a customer', () => { + const client = new Client(); + + beforeEach(() => mockTwitter.setUpSuccessfulMock(client)); + beforeEach(() => fetchMock.catch(503)); + afterEach(fetchMock.restore); + + let promise; + beforeEach(() => { + promise = new TwitterUsername(client, TwitterUsername.makeHref('SYNC')) + .fetch() + .then(username => username); + }); + + it('should resolve the promise', () => promise.should.be.fulfilled); + it('should set the href', () => promise.then(v => v.href).should.eventually.equal('/1/SYNC/twitter/username')); + it('should set the username', () => promise.then(v => v.username).should.eventually.equal('GMVSYNC')); + it('should set is_valid', () => promise.then(v => v.is_valid).should.eventually.equal(true)); + it('should set the profile image url', () => promise.then(v => v.profile_image_url).should.eventually.equal('https://example.com/gmvsync.png')); + it('should be hydrated', () => promise.then(v => v.hydrated).should.eventually.equal(true)); +});