Skip to content

Commit

Permalink
feat(voip): Add call participant management endpoints. (#61)
Browse files Browse the repository at this point in the history
Allows API consumers to add participants to and remove participants from
existing VOIP calls.

Resolves EN-4256.

Signed-off-by: Jeff Cuevas-Koch <[email protected]>
  • Loading branch information
cuevaskoch authored Aug 5, 2019
1 parent 0bfceb1 commit d1b9d96
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 1 deletion.
48 changes: 47 additions & 1 deletion src/examples/create_and_end_calls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import fetchMock from 'fetch-mock';
import Track from '../index';
import { charlie, calls as mockCalls } from '../mocks';
import {
charlie,
calls as mockCalls,
callParticipants as mockParticipants,
} from '../mocks';

chai.should();
chai.use(chaiAsPromised);
Expand Down Expand Up @@ -63,3 +67,45 @@ describe('When ending a call', () => {
return callPromise;
});
});

describe('When adding a participant to a call', () => {
const api = new Track({ autoRenew: false });

beforeEach(() => charlie.setUpSuccessfulMock(api.client));
beforeEach(() => mockParticipants.setUpSuccessfulMock(api.client));
beforeEach(() => fetchMock.catch(503));
afterEach(fetchMock.restore);

it('should add the participant', () => {
api.logIn({ username: '[email protected]', password: 'securepassword' });

const callId = 2;
const newParticipant = { type: 'user', user: '/1/SYNC/users/1' };
const participantPromise = api.customer('SYNC').callParticipant(callId, newParticipant)
.create()
.then(participant => participant);

return participantPromise;
});
});

describe('When removing a participant from a call', () => {
const api = new Track({ autoRenew: false });

beforeEach(() => charlie.setUpSuccessfulMock(api.client));
beforeEach(() => mockParticipants.setUpSuccessfulMock(api.client));
beforeEach(() => fetchMock.catch(503));
afterEach(fetchMock.restore);

it('should remove the participant', () => {
api.logIn({ username: '[email protected]', password: 'securepassword' });

const callId = 2;
const participantId = 3;
const participantPromise = api.customer('SYNC').callParticipant(callId, participantId)
.fetch()
.then(participant => participant.end());

return participantPromise;
});
});
49 changes: 49 additions & 0 deletions src/mocks/callParticipants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import fetchMock from 'fetch-mock';
import Client from '../Client';

const callParticipants = {
setUpSuccessfulMock: (client) => {
const singleResponse = () => new Response(Client.toBlob(callParticipants.getById(2, 3)));
const postResponse = () => new Response(undefined, {
headers: {
Location: '/1/SYNC/calls/2/participants/3',
},
});
const patchResponse = () => new Response(undefined, {
headers: {},
});

fetchMock
.get(client.resolve('/1/SYNC/calls/2/participants/3'), singleResponse)
.post(client.resolve('/1/SYNC/calls/2/participants'), postResponse)
.patch(client.resolve('/1/SYNC/calls/2/participants/3'), patchResponse);
},
getById: (callId, id) => callParticipants.list.find(v =>
v.call.href === `/1/SYNC/calls/${callId}` && v.id === id),
list: [
{
href: '/1/SYNC/calls/2/participants/3',
call: { href: '/1/SYNC/calls/2' },
id: 3,
type: 'user',
external_session_id: '82576c1c-9351-4da3-8df1-e3063ae285e7',
connection_requested: new Date().toISOString(),
connection_established: undefined,
connection_terminated: undefined,
user: '/1/SYNC/users/1',
},
{
href: '/1/SYNC/calls/2/participants/4',
call: { href: '/1/SYNC/calls/2' },
id: 4,
type: 'vehicle',
external_session_id: '537fafaf-9eea-4b1a-b65c-096ee4ed462f',
connection_requested: new Date().toISOString(),
connection_established: undefined,
connection_terminated: undefined,
vehicle: '/1/SYNC/vehicles/1',
},
],
};
export default callParticipants;
1 change: 1 addition & 0 deletions src/mocks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { default as agencies } from './agencies';
export { default as areas } from './areas';
export { default as blocks } from './blocks';
export { default as calls } from './calls';
export { default as callParticipants } from './callParticipants';
export { default as dispatchMessages } from './dispatchMessages';
export { default as dispatchMessageBatches } from './dispatchMessageBatches';
export { default as drivers } from './drivers';
Expand Down
85 changes: 85 additions & 0 deletions src/resources/CallParticipant.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Resource from './Resource';

/**
* CallParticipant resource
*/

class CallParticipant extends Resource {
/**
* Creates a new call participant
*
* Will populate itself with the values given to it after the client parameter
* @param {Client} client Instance of pre-configured client
* @param {Object} rest The object to use ina ssigning 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, call ID, and ID
* @param {string} customerCode Alphanumeric code of the customer
* @param {Number} callId ID of the call
* @param {Number} id Call Participant ID
* @returns {string} URI to instance of call
*/
static makeHref(customerCode, callId, id) {
return {
href: `/1/${customerCode}/calls/${callId}/participants/${id}`,
code: customerCode,
};
}

/**
* Fetches the data for this call participant via the client
* @returns {Promise} If successful, a hydrated instance of this call participant
*/
fetch() {
return this.client.get(this.href)
.then(response => response.json())
.then(callParticipant => new CallParticipant(this.client, { ...this, ...callParticipant }));
}

/**
* Adds a participant to a call via the client
* @returns {Promise} If successful, returns the call participant with IDs set
*/
create() {
const { client, hydrated, customerCode, callId, ...body } = this;
return this.client.post(`/1/${customerCode}/calls/${callId}/participants`, { body })
.then(response => response.headers.get('location'))
.then((href) => {
const match = /\/\d+\/\S+\/calls\/(\d+)\/participants\/(\d+)/.exec(href);
return new CallParticipant(this.client, {
...this,
href,
callId: parseFloat(match[1]),
id: parseFloat(match[2]),
});
});
}

/**
* Ends the call for this participant via the client
* @returns {Promise} If successful, returns instance of this call participant
*/
end() {
const { href } = CallParticipant.makeHref(this.customerCode, this.callId, this.id);
this.connection_terminated = new Date().toISOString();
return this.client.patch(href, {
body: [
{
op: 'replace',
path: '/connection_terminated',
value: this.connection_terminated,
},
],
});
}
}

export default CallParticipant;
82 changes: 82 additions & 0 deletions src/resources/CallParticipant.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import fetchMock from 'fetch-mock';
import Client from '../Client';
import CallParticipant from './CallParticipant';
import { callParticipants as mockParticipants } from '../mocks';

chai.should();
chai.use(chaiAsPromised);

describe('When instantiating a call participant based on customer and IDs', () => {
const client = new Client();
const callParticipant = new CallParticipant(client, CallParticipant.makeHref('SYNC', 2, 3));

it('should set the href', () => callParticipant.href.should.equal('/1/SYNC/calls/2/participants/3'));
it('should not be hydrated', () => callParticipant.hydrated.should.equal(false));
});

describe('When instantiating a call participant based on an object', () => {
const client = new Client();
const callParticipant = new CallParticipant(client, mockParticipants.getById(2, 3));

it('should set the ID', () => callParticipant.id.should.equal(3));
it('should set the href', () => callParticipant.href.should.equal('/1/SYNC/calls/2/participants/3'));
it('should be hydrated', () => callParticipant.hydrated.should.equal(true));
});

describe('When fetching a call participant based on customer and IDs', () => {
const client = new Client();

beforeEach(() => mockParticipants.setUpSuccessfulMock(client));
beforeEach(() => fetchMock.catch(503));
afterEach(fetchMock.restore);

let promise;
beforeEach(() => {
promise = new CallParticipant(client, CallParticipant.makeHref('SYNC', 2, 3)).fetch();
});

it('should resolve the promise', () => promise.should.be.fulfilled);
it('should set the ID', () => promise.then(v => v.id).should.eventually.equal(3));
it('should set the href', () => promise.then(v => v.href).should.eventually.equal('/1/SYNC/calls/2/participants/3'));
it('should be hydrated', () => promise.then(v => v.hydrated).should.eventually.equal(true));
});

describe('When adding a call participant', () => {
const client = new Client();

beforeEach(() => mockParticipants.setUpSuccessfulMock(client));
beforeEach(() => fetchMock.catch(503));
afterEach(fetchMock.restore);

let promise;
beforeEach(() => {
promise = new CallParticipant(client, { code: 'SYNC', callId: 2, user: '/1/SYNC/users/1' }).create();
});

it('should resolve the promise', () => promise.should.be.fulfilled);
it('should set the href', () => promise.then(v => v.href).should.eventually.equal('/1/SYNC/calls/2/participants/3'));
it('should set the ID', () => promise.then(v => v.id).should.eventually.equal(3));
});

describe('When updating a call participant', () => {
const client = new Client();

beforeEach(() => mockParticipants.setUpSuccessfulMock(client));
beforeEach(() => fetchMock.catch(503));
afterEach(fetchMock.restore);

let promise;
beforeEach(() => {
promise = new CallParticipant(client, { code: 'SYNC', callId: 2, user: '/1/SYNC/users/1' })
.create()
.then(participant => participant.end()
.then(() => participant));
});

it('should resolve the promise', () => promise.should.be.fulfilled);
it('should set the href', () => promise.then(v => v.href).should.eventually.equal('/1/SYNC/calls/2/participants/3'));
it('should set connection_terminated to a date', () => promise.then(v => v.connection_terminated).should.eventually.be.a('string'));
});

15 changes: 15 additions & 0 deletions src/resources/Customer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Assignment from './Assignment';
import Block from './Block';
import CustomerUsersContext from './CustomerUsersContext';
import Call from './Call';
import CallParticipant from './CallParticipant';
import DispatchMessage from './DispatchMessage';
import DispatchMessagesContext from './DispatchMessagesContext';
import DispatchMessageBatch from './DispatchMessageBatch';
Expand Down Expand Up @@ -124,6 +125,20 @@ class Customer extends Resource {
return this.resource(Call, { code: this.code, ...payload });
}

/**
* Gets a callParticipant resource by id
* @param {Number} callId ID of the call
* @param {Object} payload Identify of an existing or object representing a new call participant.
* @returns {CallParticipant} Call participant resource
*/
callParticipant(callId, payload) {
if (!isNaN(parseFloat(payload)) && isFinite(payload)) {
const newProperties = CallParticipant.makeHref(this.code, callId, payload);
return this.resource(CallParticipant, { callId, ...newProperties });
}
return this.resource(CallParticipant, { code: this.code, callId, ...payload });
}

/**
* Gets a context for querying this customer's dispatch messages
* @returns {DispatchMessagesContext} Context for querying this customer's dispatch messages
Expand Down
4 changes: 4 additions & 0 deletions src/resources/Customer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import Agency from './Agency';
import Area from './Area';
import AreasContext from './AreasContext';
import Block from './Block';
import Call from './Call';
import CallParticipant from './CallParticipant';
import DispatchMessage from './DispatchMessage';
import DispatchMessagesContext from './DispatchMessagesContext';
import DispatchMessageBatch from './DispatchMessageBatch';
Expand Down Expand Up @@ -51,6 +53,8 @@ describe('When getting resources related to a customer', () => {
it('should allow a list of areas to be retrieved', () => customer.areas().should.be.instanceOf(AreasContext));
it('should allow an assignment to be retrieved', () => customer.assignment().should.be.instanceOf(Assignment));
it('should allow a block to be retrieved', () => customer.block().should.be.instanceof(Block));
it('should allow a call to be retrieved', () => customer.call().should.be.instanceOf(Call));
it('should allow a call participant to be retrieved', () => customer.callParticipant().should.be.instanceOf(CallParticipant));
it('should allow dispatch messages to be searched', () => customer.dispatchMessages().should.be.instanceOf(DispatchMessagesContext));
it('should allow a dispatch message to be retrieved', () => customer.dispatchMessage().should.be.instanceOf(DispatchMessage));
it('should allow a dispatch message batch to be retrieved', () => customer.dispatchMessageBatch().should.be.instanceOf(DispatchMessageBatch));
Expand Down

0 comments on commit d1b9d96

Please sign in to comment.