Commit cac26947 authored by Robert Knight's avatar Robert Knight

Use $q rather than native Promise in `store`

Testing code that mixes native promises with $q promises (eg. as
returned by $http methods) is a PITA in tests because in that
environment:

 1. Native promises behave as usual - resolving on the next tick

 2. $q promises do not resolve until a digest is triggered explicitly.

As a result, causing a promise pipeline involving both types of promises
to fully execute is tricky.

Sticking to `$q` promises for the moment means that
`$httpBackend.flush()` will synchronously flush any resolved promises
and HTTP requests.

In future we can replace this crap with plain fetch() as part of
extracting the Hypothesis API client as a separate entity from the
client.
parent 4b03e69d
...@@ -78,15 +78,19 @@ function serializeParams(params) { ...@@ -78,15 +78,19 @@ function serializeParams(params) {
* Creates a function that will make an API call to a named route. * Creates a function that will make an API call to a named route.
* *
* @param $http - The Angular HTTP service * @param $http - The Angular HTTP service
* @param $q - The Angular Promises ($q) service.
* @param links - Object or promise for an object mapping named API routes to * @param links - Object or promise for an object mapping named API routes to
* URL templates and methods * URL templates and methods
* @param route - The dotted path of the named API route (eg. `annotation.create`) * @param route - The dotted path of the named API route (eg. `annotation.create`)
* @param {Function} tokenGetter - Function which returns a Promise for an * @param {Function} tokenGetter - Function which returns a Promise for an
* access token for the API. * access token for the API.
*/ */
function createAPICall($http, links, route, tokenGetter) { function createAPICall($http, $q, links, route, tokenGetter) {
return function (params, data) { return function (params, data) {
return Promise.all([links, tokenGetter()]).then(function (linksAndToken) { // `$q.all` is used here rather than `Promise.all` because testing code that
// mixes native Promises with the `$q` promises returned by `$http`
// functions gets awkward in tests.
return $q.all([links, tokenGetter()]).then(function (linksAndToken) {
var links = linksAndToken[0]; var links = linksAndToken[0];
var token = linksAndToken[1]; var token = linksAndToken[1];
...@@ -120,22 +124,24 @@ function createAPICall($http, links, route, tokenGetter) { ...@@ -120,22 +124,24 @@ function createAPICall($http, links, route, tokenGetter) {
* the Hypothesis API (see http://h.readthedocs.io/en/latest/api/). * the Hypothesis API (see http://h.readthedocs.io/en/latest/api/).
*/ */
// @ngInject // @ngInject
function store($http, auth, settings) { function store($http, $q, auth, settings) {
var links = retryUtil.retryPromiseOperation(function () { var links = retryUtil.retryPromiseOperation(function () {
return $http.get(settings.apiUrl); return $http.get(settings.apiUrl);
}).then(function (response) { }).then(function (response) {
return response.data.links; return response.data.links;
}); });
var tokenGetter = auth.tokenGetter; function apiCall(route) {
return createAPICall($http, $q, links, route, auth.tokenGetter);
}
return { return {
search: createAPICall($http, links, 'search', tokenGetter), search: apiCall('search'),
annotation: { annotation: {
create: createAPICall($http, links, 'annotation.create', tokenGetter), create: apiCall('annotation.create'),
delete: createAPICall($http, links, 'annotation.delete', tokenGetter), delete: apiCall('annotation.delete'),
get: createAPICall($http, links, 'annotation.read', tokenGetter), get: apiCall('annotation.read'),
update: createAPICall($http, links, 'annotation.update', tokenGetter), update: apiCall('annotation.update'),
}, },
}; };
} }
......
...@@ -25,41 +25,20 @@ describe('store', function () { ...@@ -25,41 +25,20 @@ describe('store', function () {
beforeEach(function () { beforeEach(function () {
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
var fakeTokenGetter = function () { var fakeAuth = {};
return Promise.resolve('faketoken');
};
angular.mock.module('h', { angular.mock.module('h', {
auth: { auth: fakeAuth,
tokenGetter: fakeTokenGetter,
},
settings: {apiUrl: 'http://example.com/api'}, settings: {apiUrl: 'http://example.com/api'},
}); });
});
/** angular.mock.inject(function (_$q_) {
* Wrapper around $httpBackend.flush() which allows for methods under test var $q = _$q_;
* needing to fetch data using Promise-returning functions before they fakeAuth.tokenGetter = function () {
* actually make an HTTP request. return $q.resolve('faketoken');
* };
* @param {number} [maxTicks] - Maximum number of event loop turns to wait. });
*/ });
function flushHttpRequests(maxTicks) {
try {
$httpBackend.flush();
return null;
} catch (err) {
if (maxTicks === 0) {
throw new Error('No HTTP request was made');
}
// No HTTP request was made yet. Try again on the next tick of the event
// loop.
maxTicks = maxTicks || 5;
return Promise.resolve().then(function () {
flushHttpRequests(maxTicks - 1);
});
}
}
afterEach(function () { afterEach(function () {
$httpBackend.verifyNoOutstandingExpectation(); $httpBackend.verifyNoOutstandingExpectation();
...@@ -97,70 +76,69 @@ describe('store', function () { ...@@ -97,70 +76,69 @@ describe('store', function () {
$httpBackend.flush(); $httpBackend.flush();
})); }));
it('saves a new annotation', function () { it('saves a new annotation', function (done) {
var done = store.annotation.create({}, {}).then(function (saved) { store.annotation.create({}, {}).then(function (saved) {
assert.isNotNull(saved.id); assert.isNotNull(saved.id);
done();
}); });
$httpBackend.expectPOST('http://example.com/api/annotations') $httpBackend.expectPOST('http://example.com/api/annotations')
.respond(function () { .respond(function () {
return [201, {id: 'new-id'}, {}]; return [201, {id: 'new-id'}, {}];
}); });
flushHttpRequests(); $httpBackend.flush();
return done;
}); });
it('updates an annotation', function () { it('updates an annotation', function (done) {
var done = store.annotation.update({id: 'an-id'}, {text: 'updated'}); store.annotation.update({id: 'an-id'}, {text: 'updated'}).then(function () {
done();
});
$httpBackend.expectPUT('http://example.com/api/annotations/an-id') $httpBackend.expectPUT('http://example.com/api/annotations/an-id')
.respond(function () { .respond(function () {
return [200, {}, {}]; return [200, {}, {}];
}); });
flushHttpRequests(); $httpBackend.flush();
return done;
}); });
it('deletes an annotation', function () { it('deletes an annotation', function (done) {
var done = store.annotation.delete({id: 'an-id'}, {}); store.annotation.delete({id: 'an-id'}, {}).then(function () {
done();
});
$httpBackend.expectDELETE('http://example.com/api/annotations/an-id') $httpBackend.expectDELETE('http://example.com/api/annotations/an-id')
.respond(function () { .respond(function () {
return [200, {}, {}]; return [200, {}, {}];
}); });
flushHttpRequests(); $httpBackend.flush();
return done;
}); });
it('removes internal properties before sending data to the server', function () { it('removes internal properties before sending data to the server', function (done) {
var annotation = { var annotation = {
$highlight: true, $highlight: true,
$notme: 'nooooo!', $notme: 'nooooo!',
allowed: 123, allowed: 123,
}; };
var done = store.annotation.create({}, annotation); store.annotation.create({}, annotation).then(function () {
done();
});
$httpBackend.expectPOST('http://example.com/api/annotations', { $httpBackend.expectPOST('http://example.com/api/annotations', {
allowed: 123, allowed: 123,
}) })
.respond(function () { return [200, {id: 'test'}, {}]; }); .respond(function () { return [200, {id: 'test'}, {}]; });
flushHttpRequests(); $httpBackend.flush();
return done;
}); });
// Our backend service interprets semicolons as query param delimiters, so we // Our backend service interprets semicolons as query param delimiters, so we
// must ensure to encode them in the query string. // must ensure to encode them in the query string.
it('encodes semicolons in query parameters', function () { it('encodes semicolons in query parameters', function (done) {
var done = store.search({'uri': 'http://example.com/?foo=bar;baz=qux'}); store.search({'uri': 'http://example.com/?foo=bar;baz=qux'}).then(function () {
done();
});
$httpBackend.expectGET('http://example.com/api/search?uri=http%3A%2F%2Fexample.com%2F%3Ffoo%3Dbar%3Bbaz%3Dqux') $httpBackend.expectGET('http://example.com/api/search?uri=http%3A%2F%2Fexample.com%2F%3Ffoo%3Dbar%3Bbaz%3Dqux')
.respond(function () { return [200, {}, {}]; }); .respond(function () { return [200, {}, {}]; });
flushHttpRequests(); $httpBackend.flush();
return done;
}); });
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment