<!DOCTYPE html>
<html>
<head>
<link data-require="jasmine@2.0.0" data-semver="2.0.0" rel="stylesheet" href="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.css" />
<script data-require="jasmine@2.0.0" data-semver="2.0.0" src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine.js"></script>
<script data-require="jasmine@2.0.0" data-semver="2.0.0" src="//cdn.jsdelivr.net/jasmine/2.0.0/jasmine-html.js"></script>
<script data-require="jasmine@2.0.0" data-semver="2.0.0" src="//cdn.jsdelivr.net/jasmine/2.0.0/boot.js"></script>
<script src="attendee.js"></script>
<script src="fakeAttendeeWebApi.js"></script>
<script src="attendeeWebApiDecorator.js"></script>
<!--
<script src="fakeAttendeeWebApi_tests.js"></script>
-->
<script src="attendeeWebApiDecorator_tests.js"></script>
</head>
<body></body>
</html>
This plunk shows how to solve a common HTTP problem with Promises. The material is excerpted from the upcoming book, *Reliable JavaScript*, co-authored by Lawrence D. Spencer and Seth H. Richards (Wiley Publishing), as presented at Boston Code Camp 22 in November, 2014.
The `fakeAttendeeWebApi` module imitates a Web API service that can POST and GET attendees at a conference, through methods `post` and `getAll`. The problem is that if a `getAll` is issued soon after a `post`, then the `getAll` may not include the newly `post`ed data. The problem is solved using a `Promise`-based decorator, `attendeeWebApiDecorator`.
See the Jasmine unit tests in `attendeeWebApiDecorator_tests.js` for the expected behavior.
var Conference = Conference || {};
Conference.attendee = function(firstName, lastName){
var attendeeId,
checkedIn = false,
first = firstName || 'None',
last = lastName || 'None',
checkInNumber;
return {
setId: function(id) {
attendeeId = id;
},
getId: function() {
return attendeeId;
},
getFullName: function(){
return first + ' ' + last;
},
isCheckedIn: function(){
return checkedIn;
},
checkIn: function(){
checkedIn = true;
},
undoCheckIn: function() {
checkedIn = false;
checkInNumber = undefined;
},
setCheckInNumber: function(number) {
checkInNumber = number;
},
getCheckInNumber: function() {
return checkInNumber;
},
// Tells whether this attendee is the same person as another attendee.
// To keep things simple, the comparison is based on the name only.
// In real life, there would be other data such as a date of birth.
isSamePersonAs: function(otherAttendee) {
return otherAttendee !== undefined
&& otherAttendee.getFullName() === this.getFullName();
},
// Return a copy of this attendee
copy: function() {
var copy = Conference.attendee(first,last);
copy.setId(this.getId());
copy.checkIn(this.isCheckedIn());
copy.setCheckInNumber(this.getCheckInNumber());
return copy;
}
};
}
var Conference = Conference || {};
// A fake version of attendeeWebApi. It has the same methods as the real one,
// but is entirely client-side.
Conference.fakeAttendeeWebApi = function(){
var attendees = []; // Fake database table.
return {
// Pretend to POST the attendee to the server.
// Returns a Promise that resolves to a copy of the attendee
// (to mimic getting a new version from the server), which
// will at that point have a primary key (attendeeId) that was
// assigned by the database.
// If a test requires the Promise to reject, use a spy.
post: function post(attendee) {
return new Promise( function(resolve, reject) {
// setTimeout, even with a delay of only 5 milliseconds, causes
// the resolution of the promise to be delayed to the next turn.
setTimeout(function pretendPostingToServer() {
var copyOfAttendee = attendee.copy();
copyOfAttendee.setId(attendees.length+1);
attendees.push(copyOfAttendee);
resolve(copyOfAttendee);
},5);
});
},
// Return a Promise for all attendees. This Promise always resolves,
// but in testing a spy can make it reject if necessary.
getAll: function getAll() {
return new Promise( function(resolve,reject) {
// This setTimeout has a shorter delay than post's,
// to imitate the conditions observed in real life.
setTimeout(function pretendToGetAllFromServer() {
var copies = [];
attendees.forEach(function(a) {
copies.push(a.copy());
});
resolve(copies);
},1);
});
}
};
};
describe('fakeAttendeeWebApi', function() {
var webApi,
attendeeA,
attendeeB;
beforeEach(function() {
webApi = Conference.fakeAttendeeWebApi();
attendeeA = Conference.attendee('Mariano','Tezanos');
attendeeB = Conference.attendee('Gregorio ','Perez');
});
describe('post(attendee)', function() {
it('if successful, resolves to an attendee with an ID',
function(done/*See Chapter 6*/) {
webApi.post(attendeeA).then(
function promiseResolved(attendee) {
expect(attendee.getId()).not.toBeUndefined();
done();
},
function promiseRejected() {
expect('Promise rejected').toBe(false);
done();
});
});
});
describe('getAll()', function () {
it('returns a resolved promise for all attendees posted'
+' if you wait for their promises to resolve', function (done) {
webApi.post(attendeeA)
.then(function() {
return webApi.post(attendeeB);
})
.then(function() {
return webApi.getAll();
})
.then(
function promiseResolved(attendees) {
expect(attendees[0].getFullName()).toEqual(attendeeA.getFullName());
expect(attendees[1].getFullName()).toEqual(attendeeB.getFullName());
done();
},
function promiseRejected() {
expect('Promise rejected').toBe(false);
done();
});
});
it('does not include attendees whose posts are not resolved',
function(done) {
webApi.post(attendeeA);
webApi.post(attendeeB);
webApi.getAll().then(
function promiseResolved(attendees) {
expect(attendees.length).toBe(0);
done();
},
function promiseRejected() {
expect('Promise rejected').toBe(false);
done();
});
});
});
});
var Conference = Conference || {};
Conference.attendeeWebApiDecorator = function(baseWebApi){
var self = this,
// The records passed to the post function,
// whose calls are not yet resolved.
pendingPosts = [],
messages = {
postPending: 'It appears that a post is pending for this attendee'
}
// Return the element of 'posts' that is for the attendee,
// or -1 if there is no such element.
function indexOfPostForSameAttendee(posts,attendee) {
var ix;
for (ix=0; ix<posts.length; ++ix) {
if (posts[ix].isSamePersonAs(attendee)) {
return ix;
}
}
return -1;
}
return {
post: function post(attendee) {
if (indexOfPostForSameAttendee(pendingPosts, attendee) >=0 ) {
return Promise.reject(new Error(messages.postPending));
}
pendingPosts.push(attendee);
return baseWebApi.post(attendee).then(
function onPostSucceeded(attendeeWithId) {
// When the post returns the attendee with an ID, put the ID in
// the pending record because that record may have been added to
// a getAll result and we want that result to benefit from the ID.
var ix = pendingPosts.indexOf(attendee);
if (ix >= 0) {
pendingPosts[ix].setId(attendeeWithId.getId());
pendingPosts.splice(ix, 1);
}
return attendeeWithId;
},
function onPostFailed(reason) {
var ix = pendingPosts.indexOf(attendee);
if (ix >= 0) {
pendingPosts.splice(ix, 1);
}
return Promise.reject(reason);
});
},
getAll: function getAll() {
return baseWebApi.getAll().then(function(records) {
pendingPosts.forEach(function(pending) {
var ix = indexOfPostForSameAttendee(records,pending);
if (ix<0) {
records.push(pending);
}
});
return records;
});
},
getMessages: function getMessages() {
return messages;
}
};
};
describe('attendeeWebApiDecorator', function() {
var decoratedWebApi,
baseWebApi,
attendeeA,
attendeeB,
underlyingFailure = 'Failure in underlying function';
// Execute decoratedWebApi.getAll(), expecting it to return a resolved
// Promise.
// done - The prevailing Jasmine done() function for async support.
// expectation - A function that gives expectations on the returned
// attendees.
function getAllWithSuccessExpectation(done,expectation) {
decoratedWebApi.getAll().then(
function onSuccess(attendees) {
expectation(attendees);
done();
},
function onFailure() {
expect('Failed in getAll').toBe(false);
done();
});
}
beforeEach(function() {
baseWebApi = Conference.fakeAttendeeWebApi();
decoratedWebApi = Conference.attendeeWebApiDecorator(baseWebApi);
attendeeA = Conference.attendee('Mariano','Tezanos');
attendeeB = Conference.attendee('Gregorio ','Perez');
});
describe('post(attendee)', function() {
describe('on success of the underlying post', function() {
it('returns a Promise that resolves to an attendee with ID',
function(done) {
decoratedWebApi.post(attendeeA).then(
function onSuccess(attendee) {
expect(attendee.getFullName()).toBe(attendeeA.getFullName());
expect(attendee.getId()).not.toBeUndefined();
done();
},
function onFailure() {
expect('Failed').toBe(false);
done();
});
});
it('causes an immediate getAll to include the record without ID',
function(done) {
decoratedWebApi.post(attendeeA);
// Execute getAll without waiting for the post to resolve.
getAllWithSuccessExpectation(done, function onSuccess(attendees) {
expect(attendees.length).toBe(1);
expect(attendees[0].getId()).toBeUndefined();
});
});
it('causes a delayed getAll to include the record with ID',
function(done) {
decoratedWebApi.post(attendeeA).then(function() {
// This time execute getAll after post resolves.
getAllWithSuccessExpectation(done, function onSuccess(attendees) {
expect(attendees.length).toBe(1);
expect(attendees[0].getId()).not.toBeUndefined();
});
});
});
it('fills in IDs of records already appended to getAll',function(done){
var recordsFromGetAll, promiseFromPostA;
// Issue the post and don't wait for it.
promiseFromPostA = decoratedWebApi.post(attendeeA);
// Immediately issue the getAll, and capture its results.
decoratedWebApi.getAll().then(function onSuccess(attendees) {
recordsFromGetAll = attendees;
expect(recordsFromGetAll[0].getId()).toBeUndefined();
});
// Now wait for the post to finally resolve. (Remember that
// its timeout is longer than getAll's.) When it does resolve,
// We should see the attendeeId appear in the pending record that
// getAll() obtained.
promiseFromPostA.then(function() {
expect(recordsFromGetAll[0].getId()).not.toBeUndefined();
done();
});
});
});
describe('on failure of the underlying post', function() {
beforeEach(function() {
// Cause the base's post to fail, but not until the next turn.
spyOn(baseWebApi,'post').and.returnValue(
new Promise( function(resolve,reject) {
setTimeout(function() {
reject(underlyingFailure);
},5);
}));
});
it('returns a Promise rejected with the underlying reason',function() {
decoratedWebApi.post(attendeeA).then(
function onSuccessfulPost() {
expect('Post succeeded').toBe(false);
done();
},
function onRejectedPost(reason) {
expect(reason).toBe(underlyingFailure);
done();
});
});
it('still allows an immediate getAll to include the record without an ID',
function(done) {
decoratedWebApi.post(attendeeA);
getAllWithSuccessExpectation(done, function onSuccess(attendees) {
expect(attendees.length).toBe(1);
});
});
it('causes a delayed getAll to exclude the record',
function(done) {
decoratedWebApi.post(attendeeA).then(
function onSuccessfulPost() {
expect('Post succeeded').toBe(false);
done();
},
function onRejectedPost() {
getAllWithSuccessExpectation(done, function onSuccess(attendees) {
expect(attendees.length).toBe(0);
});
});
});
});
describe('when called for an attendee just posted', function() {
it('returns a rejected promise',function(done) {
decoratedWebApi.post(attendeeA);
decoratedWebApi.post(attendeeA).then(
function onSuccess() {
expect('Post succeeded').toBe(false);
done();
},
function onFailure(error) {
expect(error instanceof Error).toBe(true);
expect(error.message).toBe(
decoratedWebApi.getMessages().postPending);
done();
});
});
});
});
describe('getAll()', function() {
describe('on success of underlying getAll', function() {
it('returns a Promise for all processed records, '
+'if there are none pending',function(done) {
spyOn(baseWebApi,'getAll').and.returnValue(
new Promise( function(resolve,reject) {
setTimeout(function() {
resolve([attendeeA,attendeeB]);
},1);
}));
getAllWithSuccessExpectation(done,function onSuccess(attendees) {
expect(attendees.length).toBe(2);
});
});
it('returns a Promise for all processed records plus all pending ones',
function(done) {
decoratedWebApi.post(attendeeA).then(function() {
decoratedWebApi.post(attendeeB); // Leave pending.
getAllWithSuccessExpectation(done,function onSuccess(attendees) {
expect(attendees.length).toBe(2);
expect(attendees[0].getId()).not.toBeUndefined();
expect(attendees[1].getId()).toBeUndefined();
});
});
});
});
describe('on failure of underlying getAll', function() {
it('returns the underlying rejected Promise', function(done) {
spyOn(baseWebApi,'getAll').and.returnValue(
new Promise( function(resolve,reject) {
setTimeout(function() {
reject(underlyingFailure);
},1);
}));
decoratedWebApi.getAll().then(
function onSuccess() {
expect('Underlying getAll succeeded').toBe(false);
done();
},
function onFailure(reason) {
expect(reason).toBe(underlyingFailure);
done();
});
});
});
});
});