From 31925e7eb58044d1e3b252e6a0ebd2243e56bda3 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 3 Aug 2011 13:53:59 +0200 Subject: [PATCH 01/28] feat($browser): xhr returns raw request object --- src/angular-mocks.js | 1 + src/service/browser.js | 5 +++++ test/service/browserSpecs.js | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index f5907a42b684..73bc4cbd93c7 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -120,6 +120,7 @@ angular.module.ngMock.$Browser = function() { }); callback(expectation.code, expectation.response); }); + // TODO(vojta): return mock request object }; self.xhr.expectations = expectations; self.xhr.requests = requests; diff --git a/src/service/browser.js b/src/service/browser.js index 2e2c07e8ad95..da82b9b0d718 100644 --- a/src/service/browser.js +++ b/src/service/browser.js @@ -90,8 +90,12 @@ function Browser(window, document, body, XHR, $log, $sniffer) { *
  • X-Requested-With: XMLHttpRequest
  • * * + * @returns {XMLHttpRequest|undefined} Raw XMLHttpRequest object or undefined when JSONP method + * * @description * Send ajax request + * + * TODO(vojta): change signature of this method to (method, url, data, headers, callback) */ self.xhr = function(method, url, post, callback, headers) { outstandingRequestCount ++; @@ -124,6 +128,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { } }; xhr.send(post || ''); + return xhr; } }; diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index 5234f0bef5aa..7e50a2809ecd 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -223,6 +223,10 @@ describe('browser', function() { expect(code).toEqual(202); expect(response).toEqual('RESPONSE'); }); + + it('should return raw xhr object', function() { + expect(browser.xhr('GET', '/url', null, noop)).toBe(xhr); + }); }); describe('defer', function() { From 42a2548cf5e81f23f3d0183e88fe5f06583a69b8 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 10 Aug 2011 15:59:55 +0200 Subject: [PATCH 02/28] fix($browser.xhr): change method "JSON" to "JSONP" Breaks "JSON" xhr method is now called "JSONP" --- docs/content/cookbook/buzz.ngdoc | 4 ++-- src/service/browser.js | 2 +- src/service/resource.js | 4 ++-- src/service/xhr.js | 8 ++++---- test/service/browserSpecs.js | 10 +++++----- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/content/cookbook/buzz.ngdoc b/docs/content/cookbook/buzz.ngdoc index c4e5ae371e4d..ca6a22b49a26 100644 --- a/docs/content/cookbook/buzz.ngdoc +++ b/docs/content/cookbook/buzz.ngdoc @@ -18,8 +18,8 @@ to retrieve Buzz activity and comments. this.Activity = $resource( 'https://www.googleapis.com/buzz/v1/activities/:userId/:visibility/:activityId/:comments', {alt: 'json', callback: 'JSON_CALLBACK'}, - { get: {method: 'JSON', params: {visibility: '@self'}}, - replies: {method: 'JSON', params: {visibility: '@self', comments: '@comments'}} + { get: {method: 'JSONP', params: {visibility: '@self'}}, + replies: {method: 'JSONP', params: {visibility: '@self', comments: '@comments'}} }); } BuzzController.prototype = { diff --git a/src/service/browser.js b/src/service/browser.js index da82b9b0d718..65a63f62aa27 100644 --- a/src/service/browser.js +++ b/src/service/browser.js @@ -99,7 +99,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { */ self.xhr = function(method, url, post, callback, headers) { outstandingRequestCount ++; - if (lowercase(method) == 'json') { + if (lowercase(method) == 'jsonp') { var callbackId = ("angular_" + Math.random() + '_' + (idCounter++)).replace(/\d\./, ''); window[callbackId] = function(data) { window[callbackId].data = data; diff --git a/src/service/resource.js b/src/service/resource.js index 969e4be1cd85..2082b9ed94e7 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -40,7 +40,7 @@ * - `action` – {string} – The name of action. This name becomes the name of the method on your * resource object. * - `method` – {string} – HTTP request method. Valid methods are: `GET`, `POST`, `PUT`, `DELETE`, - * and `JSON` (also known as JSONP). + * and `JSONP` * - `params` – {object=} – Optional set of pre-bound parameters for this action. * - isArray – {boolean=} – If true then the returned object for this action is an array, see * `returns` section. @@ -163,7 +163,7 @@ this.Activity = $resource( 'https://www.googleapis.com/buzz/v1/activities/:userId/:visibility/:activityId/:comments', {alt:'json', callback:'JSON_CALLBACK'}, - {get:{method:'JSON', params:{visibility:'@self'}}, replies: {method:'JSON', params:{visibility:'@self', comments:'@comments'}}} + {get:{method:'JSONP', params:{visibility:'@self'}}, replies: {method:'JSONP', params:{visibility:'@self', comments:'@comments'}}} ); } diff --git a/src/service/xhr.js b/src/service/xhr.js index d9c78fd65729..7970622b68f4 100644 --- a/src/service/xhr.js +++ b/src/service/xhr.js @@ -85,7 +85,7 @@ * {@link http://en.wikipedia.org/wiki/Rainbow_table salt for added security}. * * @param {string} method HTTP method to use. Valid values are: `GET`, `POST`, `PUT`, `DELETE`, and - * `JSON`. `JSON` is a special case which causes a + * `JSONP`. `JSONP` is a special case which causes a * [JSONP](http://en.wikipedia.org/wiki/JSON#JSONP) cross domain request using script tag * insertion. * @param {string} url Relative or absolute URL specifying the destination of the request. For @@ -135,13 +135,13 @@

    - - + +
    code={{code}}
    response={{response}}
    diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index 7e50a2809ecd..41f17f2a3696 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -111,7 +111,7 @@ describe('browser', function() { }); describe('xhr', function() { - describe('JSON', function() { + describe('JSONP', function() { var log; function callback(code, data) { @@ -129,7 +129,7 @@ describe('browser', function() { it('should add script tag for JSONP request', function() { var notify = jasmine.createSpy('notify'); - browser.xhr('JSON', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); browser.notifyWhenNoOutstandingRequests(notify); expect(notify).not.toHaveBeenCalled(); expect(scripts.length).toEqual(1); @@ -148,7 +148,7 @@ describe('browser', function() { it('should call callback when script fails to load', function() { - browser.xhr('JSON', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); var script = scripts[0]; expect(typeof script.onload).toBe('function'); expect(typeof script.onerror).toBe('function'); @@ -160,7 +160,7 @@ describe('browser', function() { it('should update the outstandingRequests counter for successful requests', function() { var notify = jasmine.createSpy('notify'); - browser.xhr('JSON', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); browser.notifyWhenNoOutstandingRequests(notify); expect(notify).not.toHaveBeenCalled(); @@ -175,7 +175,7 @@ describe('browser', function() { it('should update the outstandingRequests counter for failed requests', function() { var notify = jasmine.createSpy('notify'); - browser.xhr('JSON', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); browser.notifyWhenNoOutstandingRequests(notify); expect(notify).not.toHaveBeenCalled(); From d05bd63aa651593e9f513dfc0df4abaf6b625f3b Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 10 Aug 2011 16:03:26 +0200 Subject: [PATCH 03/28] fix($browser.xhr): respond with internal -2 status on jsonp error If jsonp is not successfull, we return internal status -2. This internal status should by normalized by $xhr into 0, but $xhr needs to distinguish between jsonp-error/abort/timeout (all status 0). --- src/service/browser.js | 2 +- src/service/xhr.js | 2 +- test/service/browserSpecs.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/service/browser.js b/src/service/browser.js index 65a63f62aa27..49bfe99edfc6 100644 --- a/src/service/browser.js +++ b/src/service/browser.js @@ -109,7 +109,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { if (window[callbackId].data) { completeOutstandingRequest(callback, 200, window[callbackId].data); } else { - completeOutstandingRequest(callback); + completeOutstandingRequest(callback, -2); } delete window[callbackId]; body[0].removeChild(script); diff --git a/src/service/xhr.js b/src/service/xhr.js index 7970622b68f4..e9421caf3245 100644 --- a/src/service/xhr.js +++ b/src/service/xhr.js @@ -165,7 +165,7 @@ function() { element(':button:contains("Invalid JSONP")').click(); element(':button:contains("fetch")').click(); - expect(binding('code')).toBe('code='); + expect(binding('code')).toBe('code=-2'); expect(binding('response')).toBe('response=Request failed'); }); diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index 41f17f2a3696..a3ec648606e1 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -147,14 +147,14 @@ describe('browser', function() { }); - it('should call callback when script fails to load', function() { + it('should call callback with status -2 when script fails to load', function() { browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); var script = scripts[0]; expect(typeof script.onload).toBe('function'); expect(typeof script.onerror).toBe('function'); script.onerror(); - expect(log).toEqual('undefined:undefined;'); + expect(log).toEqual('-2:undefined;'); }); From c240ab6eec63803f0f9c4d08f65e777104ad52ec Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Thu, 18 Aug 2011 23:43:25 +0200 Subject: [PATCH 04/28] fix($browser.xhr): fix IE6, IE7 bug - sync xhr when serving from cache IE6, IE7 is sync when serving content from cache. We want consistent api, so we have to use setTimeout to make it async. --- src/service/browser.js | 27 ++++++++++++++++++++------- test/service/browserSpecs.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/service/browser.js b/src/service/browser.js index 49bfe99edfc6..b38c9211ba69 100644 --- a/src/service/browser.js +++ b/src/service/browser.js @@ -73,6 +73,11 @@ function Browser(window, document, body, XHR, $log, $sniffer) { } } + // normalize IE bug (http://bugs.jquery.com/ticket/1450) + function fixStatus(status) { + return status == 1223 ? 204 : status; + } + /** * @ngdoc method * @name angular.module.ng.$browser#xhr @@ -120,14 +125,22 @@ function Browser(window, document, body, XHR, $log, $sniffer) { forEach(headers, function(value, key) { if (value) xhr.setRequestHeader(key, value); }); - xhr.onreadystatechange = function() { - if (xhr.readyState == 4) { - // normalize IE bug (http://bugs.jquery.com/ticket/1450) - var status = xhr.status == 1223 ? 204 : xhr.status; - completeOutstandingRequest(callback, status, xhr.responseText); - } - }; + xhr.send(post || ''); + + // IE6, IE7 bug - does sync when serving from cache + if (xhr.readyState == 4) { + setTimeout(function() { + completeOutstandingRequest(callback, fixStatus(xhr.status), xhr.responseText); + }, 0); + } else { + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + completeOutstandingRequest(callback, fixStatus(xhr.status), xhr.responseText); + } + }; + } + return xhr; } }; diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index a3ec648606e1..05c1dec0528f 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -227,6 +227,35 @@ describe('browser', function() { it('should return raw xhr object', function() { expect(browser.xhr('GET', '/url', null, noop)).toBe(xhr); }); + + it('should be async even if xhr.send() is sync', function() { + // IE6, IE7 is sync when serving from cache + var xhr; + function FakeXhr() { + xhr = this; + this.open = this.setRequestHeader = noop; + this.send = function() { + this.status = 200; + this.responseText = 'response'; + this.readyState = 4; + }; + } + + var callback = jasmine.createSpy('done').andCallFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('response'); + }); + + browser = new Browser(fakeWindow, jqLite(window.document), null, FakeXhr, null); + browser.xhr('GET', '/url', null, callback); + expect(callback).not.toHaveBeenCalled(); + + fakeWindow.setTimeout.flush(); + expect(callback).toHaveBeenCalledOnce(); + + (xhr.onreadystatechange || noop)(); + expect(callback).toHaveBeenCalledOnce(); + }); }); describe('defer', function() { From d0c3c432df6d785a0e565b1e5d53abdc7be10c19 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Thu, 18 Aug 2011 23:48:01 +0200 Subject: [PATCH 05/28] feat($browser.xhr): add timeout option to abort request Timeouted request responds internal status code -1, which should be normalized into 0 by $xhr. --- src/service/browser.js | 16 +++++++++++++--- test/service/browserSpecs.js | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/service/browser.js b/src/service/browser.js index b38c9211ba69..74bea44caf1b 100644 --- a/src/service/browser.js +++ b/src/service/browser.js @@ -95,6 +95,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { *
  • X-Requested-With: XMLHttpRequest
  • * * + * @param {number=} timeout Timeout in ms, when the request will be aborted * @returns {XMLHttpRequest|undefined} Raw XMLHttpRequest object or undefined when JSONP method * * @description @@ -102,7 +103,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { * * TODO(vojta): change signature of this method to (method, url, data, headers, callback) */ - self.xhr = function(method, url, post, callback, headers) { + self.xhr = function(method, url, post, callback, headers, timeout) { outstandingRequestCount ++; if (lowercase(method) == 'jsonp') { var callbackId = ("angular_" + Math.random() + '_' + (idCounter++)).replace(/\d\./, ''); @@ -126,21 +127,30 @@ function Browser(window, document, body, XHR, $log, $sniffer) { if (value) xhr.setRequestHeader(key, value); }); + var status; xhr.send(post || ''); // IE6, IE7 bug - does sync when serving from cache if (xhr.readyState == 4) { setTimeout(function() { - completeOutstandingRequest(callback, fixStatus(xhr.status), xhr.responseText); + completeOutstandingRequest(callback, fixStatus(status || xhr.status), xhr.responseText); }, 0); } else { xhr.onreadystatechange = function() { if (xhr.readyState == 4) { - completeOutstandingRequest(callback, fixStatus(xhr.status), xhr.responseText); + completeOutstandingRequest(callback, fixStatus(status || xhr.status), + xhr.responseText); } }; } + if (timeout > 0) { + setTimeout(function() { + status = -1; + xhr.abort(); + }, timeout); + } + return xhr; } }; diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index 05c1dec0528f..566ffb09727f 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -228,6 +228,23 @@ describe('browser', function() { expect(browser.xhr('GET', '/url', null, noop)).toBe(xhr); }); + it('should abort request on timeout', function() { + var callback = jasmine.createSpy('done').andCallFake(function(status, response) { + expect(status).toBe(-1); + }); + + browser.xhr('GET', '/url', null, callback, {}, 2000); + xhr.abort = jasmine.createSpy('xhr.abort'); + + fakeWindow.setTimeout.flush(); + expect(xhr.abort).toHaveBeenCalledOnce(); + + xhr.status = 0; + xhr.readyState = 4; + xhr.onreadystatechange(); + expect(callback).toHaveBeenCalledOnce(); + }); + it('should be async even if xhr.send() is sync', function() { // IE6, IE7 is sync when serving from cache var xhr; From 15dea1e21ab345a167c7b5d78901a954113e9498 Mon Sep 17 00:00:00 2001 From: Igor Minar Date: Wed, 16 Feb 2011 20:04:39 -0500 Subject: [PATCH 06/28] feat($cacheFactory): add general purpose $cacheFactory service --- angularFiles.js | 1 + src/AngularPublic.js | 1 + src/service/cacheFactory.js | 152 +++++++++++++++ test/service/cacheFactorySpec.js | 317 +++++++++++++++++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 src/service/cacheFactory.js create mode 100644 test/service/cacheFactorySpec.js diff --git a/angularFiles.js b/angularFiles.js index 2a102a18b1e0..1b2dc56cd28b 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -9,6 +9,7 @@ angularFiles = { 'src/jqLite.js', 'src/apis.js', 'src/service/browser.js', + 'src/service/cacheFactory.js', 'src/service/compiler.js', 'src/service/cookieStore.js', 'src/service/cookies.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 663011045786..81708bf5836e 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -67,6 +67,7 @@ function ngModule($provide, $injector) { }); $provide.service('$browser', $BrowserProvider); + $provide.service('$cacheFactory', $CacheFactoryProvider); $provide.service('$compile', $CompileProvider); $provide.service('$cookies', $CookiesProvider); $provide.service('$cookieStore', $CookieStoreProvider); diff --git a/src/service/cacheFactory.js b/src/service/cacheFactory.js new file mode 100644 index 000000000000..ccc6931355c7 --- /dev/null +++ b/src/service/cacheFactory.js @@ -0,0 +1,152 @@ +/** + * @ngdoc object + * @name angular.module.ng.$cacheFactory + * + * @description + * Factory that constructs cache objects. + * + * + * @param {string} cacheId Name or id of the newly created cache. + * @param {object=} options Options object that specifies the cache behavior. Properties: + * + * - `{number=}` `capacity` — turns the cache into LRU cache. + * + * @returns {object} Newly created cache object with the following set of methods: + * + * - `{string}` `id()` — Returns id or name of the cache. + * - `{number}` `size()` — Returns number of items currently in the cache + * - `{void}` `put({string} key, {*} value)` — Puts a new key-value pair into the cache + * - `{(*}} `get({string} key) — Returns cached value for `key` or undefined for cache miss. + * - `{void}` `remove{string} key) — Removes a key-value pair from the cache. + * - `{void}` `removeAll() — Removes all cached values. + * + */ +function $CacheFactoryProvider() { + + this.$get = function() { + var caches = {}; + + function cacheFactory(cacheId, options) { + if (cacheId in caches) { + throw Error('cacheId ' + cacheId + ' taken'); + } + + var size = 0, + stats = extend({}, options, {id: cacheId}), + data = {}, + capacity = (options && options.capacity) || Number.MAX_VALUE, + lruHash = {}, + freshEnd = null, + staleEnd = null; + + return caches[cacheId] = { + + put: function(key, value) { + var lruEntry = lruHash[key] || (lruHash[key] = {key: key}); + + refresh(lruEntry); + + if (isUndefined(value)) return; + if (!(key in data)) size++; + data[key] = value; + + if (size > capacity) { + this.remove(staleEnd.key); + } + }, + + + get: function(key) { + var lruEntry = lruHash[key]; + + if (!lruEntry) return; + + refresh(lruEntry); + + return data[key]; + }, + + + remove: function(key) { + var lruEntry = lruHash[key]; + + if (lruEntry == freshEnd) freshEnd = lruEntry.p; + if (lruEntry == staleEnd) staleEnd = lruEntry.n; + link(lruEntry.n,lruEntry.p); + + delete lruHash[key]; + delete data[key]; + size--; + }, + + + removeAll: function() { + data = {}; + size = 0; + lruHash = {}; + freshEnd = staleEnd = null; + }, + + + destroy: function() { + data = null; + stats = null; + lruHash = null; + delete caches[cacheId]; + }, + + + info: function() { + return extend({}, stats, {size: size}); + } + }; + + + /** + * makes the `entry` the freshEnd of the LRU linked list + */ + function refresh(entry) { + if (entry != freshEnd) { + if (!staleEnd) { + staleEnd = entry; + } else if (staleEnd == entry) { + staleEnd = entry.n; + } + + link(entry.n, entry.p); + link(entry, freshEnd); + freshEnd = entry; + freshEnd.n = null; + } + } + + + /** + * bidirectionally links two entries of the LRU linked list + */ + function link(nextEntry, prevEntry) { + if (nextEntry != prevEntry) { + if (nextEntry) nextEntry.p = prevEntry; //p stands for previous, 'prev' didn't minify + if (prevEntry) prevEntry.n = nextEntry; //n stands for next, 'next' didn't minify + } + } + } + + + cacheFactory.info = function() { + var info = {}; + forEach(caches, function(cache, cacheId) { + info[cacheId] = cache.info(); + }); + return info; + }; + + + cacheFactory.get = function(cacheId) { + return caches[cacheId]; + }; + + + return cacheFactory; + }; +} diff --git a/test/service/cacheFactorySpec.js b/test/service/cacheFactorySpec.js new file mode 100644 index 000000000000..dc68b63d45fa --- /dev/null +++ b/test/service/cacheFactorySpec.js @@ -0,0 +1,317 @@ +describe('$cacheFactory', function() { + + it('should be injected', inject(function($cacheFactory) { + expect($cacheFactory).toBeDefined(); + })); + + + it('should return a new cache whenever called', inject(function($cacheFactory) { + var cache1 = $cacheFactory('cache1'); + var cache2 = $cacheFactory('cache2'); + expect(cache1).not.toEqual(cache2); + })); + + + it('should complain if the cache id is being reused', inject(function($cacheFactory) { + $cacheFactory('cache1'); + expect(function() { $cacheFactory('cache1'); }). + toThrow('cacheId cache1 taken'); + })); + + + describe('info', function() { + + it('should provide info about all created caches', inject(function($cacheFactory) { + expect($cacheFactory.info()).toEqual({}); + + var cache1 = $cacheFactory('cache1'); + expect($cacheFactory.info()).toEqual({cache1: {id: 'cache1', size: 0}}); + + cache1.put('foo', 'bar'); + expect($cacheFactory.info()).toEqual({cache1: {id: 'cache1', size: 1}}); + })); + }); + + + describe('get', function() { + + it('should return a cache if looked up by id', inject(function($cacheFactory) { + var cache1 = $cacheFactory('cache1'), + cache2 = $cacheFactory('cache2'); + + expect(cache1).not.toBe(cache2); + expect(cache1).toBe($cacheFactory.get('cache1')); + expect(cache2).toBe($cacheFactory.get('cache2')); + })); + }); + + describe('cache', function() { + var cache; + + beforeEach(inject(function($cacheFactory) { + cache = $cacheFactory('test'); + })); + + + describe('put, get & remove', function() { + + it('should add cache entries via add and retrieve them via get', inject(function($cacheFactory) { + cache.put('key1', 'bar'); + cache.put('key2', {bar:'baz'}); + + expect(cache.get('key2')).toEqual({bar:'baz'}); + expect(cache.get('key1')).toBe('bar'); + })); + + + it('should ignore put if the value is undefined', inject(function($cacheFactory) { + cache.put(); + cache.put('key1'); + cache.put('key2', undefined); + + expect(cache.info().size).toBe(0); + })); + + + it('should remove entries via remove', inject(function($cacheFactory) { + cache.put('k1', 'foo'); + cache.put('k2', 'bar'); + + cache.remove('k2'); + + expect(cache.get('k1')).toBe('foo'); + expect(cache.get('k2')).toBeUndefined(); + + cache.remove('k1'); + + expect(cache.get('k1')).toBeUndefined(); + expect(cache.get('k2')).toBeUndefined(); + })); + + + it('should stringify keys', inject(function($cacheFactory) { + cache.put('123', 'foo'); + cache.put(123, 'bar'); + + expect(cache.get('123')).toBe('bar'); + expect(cache.info().size).toBe(1); + + cache.remove(123); + expect(cache.info().size).toBe(0); + })); + }); + + + describe('info', function() { + + it('should size increment with put and decrement with remove', inject(function($cacheFactory) { + expect(cache.info().size).toBe(0); + + cache.put('foo', 'bar'); + expect(cache.info().size).toBe(1); + + cache.put('baz', 'boo'); + expect(cache.info().size).toBe(2); + + cache.remove('baz'); + expect(cache.info().size).toBe(1); + + cache.remove('foo'); + expect(cache.info().size).toBe(0); + })); + + + it('should return cache id', inject(function($cacheFactory) { + expect(cache.info().id).toBe('test'); + })); + }); + + + describe('removeAll', function() { + + it('should blow away all data', inject(function($cacheFactory) { + cache.put('id1', 1); + cache.put('id2', 2); + cache.put('id3', 3); + expect(cache.info().size).toBe(3); + + cache.removeAll(); + + expect(cache.info().size).toBe(0); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBeUndefined(); + expect(cache.get('id3')).toBeUndefined(); + })); + }); + + + describe('destroy', function() { + + it('should make the cache unusable and remove references to it from $cacheFactory', inject(function($cacheFactory) { + cache.put('foo', 'bar'); + cache.destroy(); + + expect(function() { cache.get('foo'); } ).toThrow(); + expect(function() { cache.get('neverexisted'); }).toThrow(); + expect(function() { cache.put('foo', 'bar'); }).toThrow(); + + expect($cacheFactory.get('test')).toBeUndefined(); + expect($cacheFactory.info()).toEqual({}); + })); + }); + }); + + + describe('LRU cache', function() { + + it('should create cache with defined capacity', inject(function($cacheFactory) { + cache = $cacheFactory('cache1', {capacity: 5}); + expect(cache.info().size).toBe(0); + + for (var i=0; i<5; i++) { + cache.put('id' + i, i); + } + + expect(cache.info().size).toBe(5); + + cache.put('id5', 5); + expect(cache.info().size).toBe(5); + cache.put('id6', 6); + expect(cache.info().size).toBe(5); + })); + + + describe('eviction', function() { + + beforeEach(inject(function($cacheFactory) { + cache = $cacheFactory('cache1', {capacity: 2}); + + cache.put('id0', 0); + cache.put('id1', 1); + })); + + + it('should kick out the first entry on put', inject(function($cacheFactory) { + cache.put('id2', 2); + expect(cache.get('id0')).toBeUndefined(); + expect(cache.get('id1')).toBe(1); + expect(cache.get('id2')).toBe(2); + })); + + + it('should refresh an entry via get', inject(function($cacheFactory) { + cache.get('id0'); + cache.put('id2', 2); + expect(cache.get('id0')).toBe(0); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBe(2); + })); + + + it('should refresh an entry via put', inject(function($cacheFactory) { + cache.put('id0', '00'); + cache.put('id2', 2); + expect(cache.get('id0')).toBe('00'); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBe(2); + })); + + + it('should not purge an entry if another one was removed', inject(function($cacheFactory) { + cache.remove('id1'); + cache.put('id2', 2); + expect(cache.get('id0')).toBe(0); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBe(2); + })); + + + it('should purge the next entry if the stalest one was removed', inject(function($cacheFactory) { + cache.remove('id0'); + cache.put('id2', 2); + cache.put('id3', 3); + expect(cache.get('id0')).toBeUndefined(); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBe(2); + expect(cache.get('id3')).toBe(3); + })); + + + it('should correctly recreate the linked list if all cache entries were removed', inject(function($cacheFactory) { + cache.remove('id0'); + cache.remove('id1'); + cache.put('id2', 2); + cache.put('id3', 3); + cache.put('id4', 4); + expect(cache.get('id0')).toBeUndefined(); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBeUndefined(); + expect(cache.get('id3')).toBe(3); + expect(cache.get('id4')).toBe(4); + })); + + + it('should blow away the entire cache via removeAll and start evicting when full', inject(function($cacheFactory) { + cache.put('id0', 0); + cache.put('id1', 1); + cache.removeAll(); + + cache.put('id2', 2); + cache.put('id3', 3); + cache.put('id4', 4); + + expect(cache.info().size).toBe(2); + expect(cache.get('id0')).toBeUndefined(); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBeUndefined(); + expect(cache.get('id3')).toBe(3); + expect(cache.get('id4')).toBe(4); + })); + + + it('should correctly refresh and evict items if operations are chained', inject(function($cacheFactory) { + cache = $cacheFactory('cache2', {capacity: 3}); + + cache.put('id0', 0); //0 + cache.put('id1', 1); //1,0 + cache.put('id2', 2); //2,1,0 + cache.get('id0'); //0,2,1 + cache.put('id3', 3); //3,0,2 + cache.put('id0', 9); //0,3,2 + cache.put('id4', 4); //4,0,3 + + expect(cache.get('id3')).toBe(3); + expect(cache.get('id0')).toBe(9); + expect(cache.get('id4')).toBe(4); + + cache.remove('id0'); //4,3 + cache.remove('id3'); //4 + cache.put('id5', 5); //5,4 + cache.put('id6', 6); //6,5,4 + cache.get('id4'); //4,6,5 + cache.put('id7', 7); //7,4,6 + + expect(cache.get('id0')).toBeUndefined(); + expect(cache.get('id1')).toBeUndefined(); + expect(cache.get('id2')).toBeUndefined(); + expect(cache.get('id3')).toBeUndefined(); + expect(cache.get('id4')).toBe(4); + expect(cache.get('id5')).toBeUndefined(); + expect(cache.get('id6')).toBe(6); + expect(cache.get('id7')).toBe(7); + + cache.removeAll(); + cache.put('id0', 0); //0 + cache.put('id1', 1); //1,0 + cache.put('id2', 2); //2,1,0 + cache.put('id3', 3); //3,2,1 + + expect(cache.info().size).toBe(3); + expect(cache.get('id0')).toBeUndefined(); + expect(cache.get('id1')).toBe(1); + expect(cache.get('id2')).toBe(2); + expect(cache.get('id3')).toBe(3); + })); + }); + }); +}); From 343d25f3a8f7567c5b91c3c3d324d6641ea3fa4d Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 5 Aug 2011 01:24:41 +0200 Subject: [PATCH 07/28] feat($http): new $http service, removing $xhr.* Features: - aborting requests - more flexible callbacks (per status code) - custom request headers (per request) - access to response headers - custom transform functions (both request, response) - caching - shortcut methods (get, head, post, put, delete, patch, jsonp) - exposing pendingCount() - setting timeout Breaks Renaming $xhr to $http Breaks Takes one parameter now - configuration object Breaks $xhr.cache removed - use configuration cache: true instead Breaks $xhr.error, $xhr.bulk removed Breaks Callback functions get parameters: response, status, headers Closes #38 Closes #80 Closes #180 Closes #299 Closes #342 Closes #395 Closes #413 Closes #414 Closes #507 --- angularFiles.js | 5 +- src/AngularPublic.js | 5 +- src/service/http.js | 428 +++++++++++++++ src/service/xhr.bulk.js | 89 --- src/service/xhr.cache.js | 118 ---- src/service/xhr.error.js | 44 -- src/service/xhr.js | 231 -------- src/widgets.js | 89 ++- test/ResourceSpec.js | 2 +- test/directivesSpec.js | 8 +- test/service/browserSpecs.js | 2 +- test/service/httpSpec.js | 983 ++++++++++++++++++++++++++++++++++ test/service/xhr.bulkSpec.js | 81 --- test/service/xhr.cacheSpec.js | 175 ------ test/service/xhr.errorSpec.js | 29 - test/service/xhrSpec.js | 271 ---------- test/widgetsSpec.js | 103 +++- 17 files changed, 1571 insertions(+), 1092 deletions(-) create mode 100644 src/service/http.js delete mode 100644 src/service/xhr.bulk.js delete mode 100644 src/service/xhr.cache.js delete mode 100644 src/service/xhr.error.js delete mode 100644 src/service/xhr.js create mode 100644 test/service/httpSpec.js delete mode 100644 test/service/xhr.bulkSpec.js delete mode 100644 test/service/xhr.cacheSpec.js delete mode 100644 test/service/xhr.errorSpec.js delete mode 100644 test/service/xhrSpec.js diff --git a/angularFiles.js b/angularFiles.js index 1b2dc56cd28b..e0191572b83a 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -31,10 +31,7 @@ angularFiles = { 'src/service/scope.js', 'src/service/sniffer.js', 'src/service/window.js', - 'src/service/xhr.bulk.js', - 'src/service/xhr.cache.js', - 'src/service/xhr.error.js', - 'src/service/xhr.js', + 'src/service/http.js', 'src/service/locale.js', 'src/directives.js', 'src/markups.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 81708bf5836e..6ec67fd40683 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -76,6 +76,7 @@ function ngModule($provide, $injector) { $provide.service('$exceptionHandler', $ExceptionHandlerProvider); $provide.service('$filter', $FilterProvider); $provide.service('$formFactory', $FormFactoryProvider); + $provide.service('$http', $HttpProvider); $provide.service('$location', $LocationProvider); $provide.service('$log', $LogProvider); $provide.service('$parse', $ParseProvider); @@ -85,9 +86,5 @@ function ngModule($provide, $injector) { $provide.service('$rootScope', $RootScopeProvider); $provide.service('$sniffer', $SnifferProvider); $provide.service('$window', $WindowProvider); - $provide.service('$xhr.bulk', $XhrBulkProvider); - $provide.service('$xhr.cache', $XhrCacheProvider); - $provide.service('$xhr.error', $XhrErrorProvider); - $provide.service('$xhr', $XhrProvider); } diff --git a/src/service/http.js b/src/service/http.js new file mode 100644 index 000000000000..13621f903499 --- /dev/null +++ b/src/service/http.js @@ -0,0 +1,428 @@ +'use strict'; + +/** + * Parse headers into key value object + * + * @param {string} headers Raw headers as a string + * @returns {Object} Parsed headers as key valu object + */ +function parseHeaders(headers) { + var parsed = {}, key, val, i; + + forEach(headers.split('\n'), function(line) { + i = line.indexOf(':'); + key = lowercase(trim(line.substr(0, i))); + val = trim(line.substr(i + 1)); + + if (key) { + if (parsed[key]) { + parsed[key] += ', ' + val; + } else { + parsed[key] = val; + } + } + }); + + return parsed; +} + +/** + * Chain all given functions + * + * This function is used for both request and response transforming + * + * @param {*} data Data to transform. + * @param {function|Array.} fns Function or an array of functions. + * @param {*=} param Optional parameter to be passed to all transform functions. + * @returns {*} Transformed data. + */ +function transform(data, fns, param) { + if (isFunction(fns)) + return fns(data); + + forEach(fns, function(fn) { + data = fn(data, param); + }); + + return data; +} + + +/** + * @ngdoc object + * @name angular.module.ng.$http + * @requires $browser + * @requires $exceptionHandler + * @requires $cacheFactory + * + * @description + */ +function $HttpProvider() { + var $config = this.defaults = { + // transform in-coming reponse data + transformResponse: function(data) { + if (isString(data)) { + if (/^\)\]\}',\n/.test(data)) data = data.substr(6); + if (/^\s*[\[\{]/.test(data) && /[\}\]]\s*$/.test(data)) + data = fromJson(data, true); + } + return data; + }, + + // transform out-going request data + transformRequest: function(d) { + return isObject(d) ? toJson(d) : d; + }, + + // default headers + headers: { + common: { + 'Accept': 'application/json, text/plain, */*', + 'X-Requested-With': 'XMLHttpRequest' + }, + post: {'Content-Type': 'application/json'}, + put: {'Content-Type': 'application/json'} + } + }; + + this.$get = ['$browser', '$exceptionHandler', '$cacheFactory', '$rootScope', + function($browser, $exceptionHandler, $cacheFactory, $rootScope) { + + var cache = $cacheFactory('$http'), + pendingRequestsCount = 0; + + // the actual service + function $http(config) { + return new XhrFuture().retry(config); + } + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$http#pendingCount + * @methodOf angular.service.$http + * + * @description + * Return number of pending requests + * + * @returns {number} Number of pending requests + */ + $http.pendingCount = function() { + return pendingRequestsCount; + }; + + /** + * @ngdoc method + * @name angular.module.ng.$http#get + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `GET` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + + /** + * @ngdoc method + * @name angular.module.ng.$http#delete + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `DELETE` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + + /** + * @ngdoc method + * @name angular.module.ng.$http#head + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `HEAD` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + + /** + * @ngdoc method + * @name angular.module.ng.$http#patch + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `PATCH` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + + /** + * @ngdoc method + * @name angular.module.ng.$http#jsonp + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `JSONP` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request. + * Should contain `JSON_CALLBACK` string. + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + createShortMethods('get', 'delete', 'head', 'patch', 'jsonp'); + + /** + * @ngdoc method + * @name angular.module.ng.$http#post + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `POST` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + + /** + * @ngdoc method + * @name angular.module.ng.$http#put + * @methodOf angular.module.ng.$http + * + * @description + * Shortcut method to perform `PUT` request + * + * @param {string} url Relative or absolute URL specifying the destination of the request + * @param {*} data Request content + * @param {Object=} config Optional configuration object + * @returns {XhrFuture} Future object + */ + createShortMethodsWithData('post', 'put'); + + return $http; + + function createShortMethods(names) { + forEach(arguments, function(name) { + $http[name] = function(url, config) { + return $http(extend(config || {}, { + method: name, + url: url + })); + }; + }); + } + + function createShortMethodsWithData(name) { + forEach(arguments, function(name) { + $http[name] = function(url, data, config) { + return $http(extend(config || {}, { + method: name, + url: url, + data: data + })); + }; + }); + } + + /** + * Represents Request object, returned by $http() + * + * !!! ACCESS CLOSURE VARS: $browser, $config, $log, $rootScope, cache, pendingRequestsCount + */ + function XhrFuture() { + var rawRequest, cfg = {}, callbacks = [], + defHeaders = $config.headers, + parsedHeaders; + + /** + * Callback registered to $browser.xhr: + * - caches the response if desired + * - calls fireCallbacks() + * - clears the reference to raw request object + */ + function done(status, response) { + // aborted request or jsonp + if (!rawRequest) parsedHeaders = {}; + + if (cfg.cache && cfg.method == 'GET' && 200 <= status && status < 300) { + parsedHeaders = parsedHeaders || parseHeaders(rawRequest.getAllResponseHeaders()); + cache.put(cfg.url, [status, response, parsedHeaders]); + } + + fireCallbacks(response, status); + rawRequest = null; + } + + /** + * Fire all registered callbacks for given status code + * + * This method when: + * - serving response from real request ($browser.xhr callback) + * - serving response from cache + * + * It does: + * - transform the response + * - call proper callbacks + * - log errors + * - apply the $scope + * - clear parsed headers + */ + function fireCallbacks(response, status) { + // transform the response + response = transform(response, cfg.transformResponse || $config.transformResponse, rawRequest); + + var regexp = statusToRegexp(status), + pattern, callback; + + pendingRequestsCount--; + + // normalize internal statuses to 0 + status = Math.max(status, 0); + for (var i = 0; i < callbacks.length; i += 2) { + pattern = callbacks[i]; + callback = callbacks[i + 1]; + if (regexp.test(pattern)) { + try { + callback(response, status, headers); + } catch(e) { + $exceptionHandler(e); + } + } + } + + $rootScope.$apply(); + parsedHeaders = null; + } + + /** + * Convert given status code number into regexp + * + * It would be much easier to convert registered statuses (e.g. "2xx") into regexps, + * but this has an advantage of creating just one regexp, instead of one regexp per + * registered callback. Anyway, probably not big deal. + * + * @param status + * @returns {RegExp} + */ + function statusToRegexp(status) { + var strStatus = status + '', + regexp = ''; + + for (var i = Math.min(0, strStatus.length - 3); i < strStatus.length; i++) { + regexp += '(' + (strStatus.charAt(i) || 0) + '|x)'; + } + + return new RegExp(regexp); + } + + /** + * This is the third argument in any user callback + * @see parseHeaders + * + * Return single header value or all headers parsed as object. + * Headers all lazy parsed when first requested. + * + * @param {string=} name Name of header + * @returns {string|Object} + */ + function headers(name) { + if (name) { + return parsedHeaders + ? parsedHeaders[lowercase(name)] || null + : rawRequest.getResponseHeader(name); + } + + parsedHeaders = parsedHeaders || parseHeaders(rawRequest.getAllResponseHeaders()); + + return parsedHeaders; + } + + /** + * Retry the request + * + * @param {Object=} config Optional config object to extend the original configuration + * @returns {XhrFuture} + */ + this.retry = function(config) { + if (rawRequest) throw 'Can not retry request. Abort pending request first.'; + + extend(cfg, config); + cfg.method = uppercase(cfg.method); + + var data = transform(cfg.data, cfg.transformRequest || $config.transformRequest), + headers = extend({'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN']}, + defHeaders.common, defHeaders[lowercase(cfg.method)], cfg.headers); + + var fromCache; + if (cfg.cache && cfg.method == 'GET' && (fromCache = cache.get(cfg.url))) { + $browser.defer(function() { + parsedHeaders = fromCache[2]; + fireCallbacks(fromCache[1], fromCache[0]); + }); + } else { + rawRequest = $browser.xhr(cfg.method, cfg.url, data, done, headers, cfg.timeout); + } + + pendingRequestsCount++; + return this; + }; + + /** + * Abort the request + */ + this.abort = function() { + if (rawRequest) { + rawRequest.abort(); + } + return this; + }; + + /** + * Register a callback function based on status code + * Note: all matched callbacks will be called, preserving registered order ! + * + * Internal statuses: + * `-2` = jsonp error + * `-1` = timeout + * `0` = aborted + * + * @example + * .on('2xx', function(){}); + * .on('2x1', function(){}); + * .on('404', function(){}); + * .on('xxx', function(){}); + * .on('20x,3xx', function(){}); + * .on('success', function(){}); + * .on('error', function(){}); + * .on('always', function(){}); + * + * @param {string} pattern Status code pattern with "x" for any number + * @param {function(*, number, Object)} callback Function to be called when response arrives + * @returns {XhrFuture} + */ + this.on = function(pattern, callback) { + var alias = { + success: '2xx', + error: '0-2,0-1,000,4xx,5xx', + always: 'xxx', + timeout: '0-1', + abort: '000' + }; + + callbacks.push(alias[pattern] || pattern); + callbacks.push(callback); + + return this; + }; + } +}]; +} + diff --git a/src/service/xhr.bulk.js b/src/service/xhr.bulk.js deleted file mode 100644 index fca96dde33c2..000000000000 --- a/src/service/xhr.bulk.js +++ /dev/null @@ -1,89 +0,0 @@ -'use strict'; - -/** - * @ngdoc object - * @name angular.module.ng.$xhr.bulk - * @requires $xhr - * @requires $xhr.error - * @requires $log - * - * @description - * - * @example - */ -function $XhrBulkProvider() { - this.$get = ['$rootScope', '$xhr', '$xhr.error', '$log', - function( $rootScope, $xhr, $error, $log) { - var requests = []; - function bulkXHR(method, url, post, success, error) { - if (isFunction(post)) { - error = success; - success = post; - post = null; - } - var currentQueue; - forEach(bulkXHR.urls, function(queue){ - if (isFunction(queue.match) ? queue.match(url) : queue.match.exec(url)) { - currentQueue = queue; - } - }); - if (currentQueue) { - if (!currentQueue.requests) currentQueue.requests = []; - var request = { - method: method, - url: url, - data: post, - success: success}; - if (error) request.error = error; - currentQueue.requests.push(request); - } else { - $xhr(method, url, post, success, error); - } - } - bulkXHR.urls = {}; - bulkXHR.flush = function(success, errorback) { - assertArgFn(success = success || noop, 0); - assertArgFn(errorback = errorback || noop, 1); - forEach(bulkXHR.urls, function(queue, url) { - var currentRequests = queue.requests; - if (currentRequests && currentRequests.length) { - queue.requests = []; - queue.callbacks = []; - $xhr('POST', url, {requests: currentRequests}, - function(code, response) { - forEach(response, function(response, i) { - try { - if (response.status == 200) { - (currentRequests[i].success || noop)(response.status, response.response); - } else if (isFunction(currentRequests[i].error)) { - currentRequests[i].error(response.status, response.response); - } else { - $error(currentRequests[i], response); - } - } catch(e) { - $log.error(e); - } - }); - success(); - }, - function(code, response) { - forEach(currentRequests, function(request, i) { - try { - if (isFunction(request.error)) { - request.error(code, response); - } else { - $error(request, response); - } - } catch(e) { - $log.error(e); - } - }); - noop(); - }); - } - }); - }; - $rootScope.$watch(function() { bulkXHR.flush(); }); - return bulkXHR; - }]; -} diff --git a/src/service/xhr.cache.js b/src/service/xhr.cache.js deleted file mode 100644 index 8a14ad99590f..000000000000 --- a/src/service/xhr.cache.js +++ /dev/null @@ -1,118 +0,0 @@ -'use strict'; - -/** - * @ngdoc object - * @name angular.module.ng.$xhr.cache - * @function - * - * @requires $xhr.bulk - * @requires $defer - * @requires $xhr.error - * @requires $log - * - * @description - * Acts just like the {@link angular.module.ng.$xhr $xhr} service but caches responses for `GET` - * requests. All cache misses are delegated to the $xhr service. - * - * @property {function()} delegate Function to delegate all the cache misses to. Defaults to - * the {@link angular.module.ng.$xhr $xhr} service. - * @property {object} data The hashmap where all cached entries are stored. - * - * @param {string} method HTTP method. - * @param {string} url Destination URL. - * @param {(string|Object)=} post Request body. - * @param {function(number, (string|Object))} success Response success callback. - * @param {function(number, (string|Object))=} error Response error callback. - * @param {boolean=} [verifyCache=false] If `true` then a result is immediately returned from cache - * (if present) while a request is sent to the server for a fresh response that will update the - * cached entry. The `success` function will be called when the response is received. - * @param {boolean=} [sync=false] in case of cache hit execute `success` synchronously. - */ -function $XhrCacheProvider() { - this.$get = ['$xhr.bulk', '$defer', '$xhr.error', '$log', - function($xhr, $defer, $error, $log) { - var inflight = {}; - function cache(method, url, post, success, error, verifyCache, sync) { - if (isFunction(post)) { - if (!isFunction(success)) { - verifyCache = success; - sync = error; - error = null; - } else { - sync = verifyCache; - verifyCache = error; - error = success; - } - success = post; - post = null; - } else if (!isFunction(error)) { - sync = verifyCache; - verifyCache = error; - error = null; - } - - if (method == 'GET') { - var data, dataCached; - if ((dataCached = cache.data[url])) { - - if (sync) { - success(200, copy(dataCached.value)); - } else { - $defer(function() { success(200, copy(dataCached.value)); }); - } - - if (!verifyCache) - return; - } - - if ((data = inflight[url])) { - data.successes.push(success); - data.errors.push(error); - } else { - inflight[url] = {successes: [success], errors: [error]}; - cache.delegate(method, url, post, - function(status, response) { - if (status == 200) - cache.data[url] = {value: response}; - var successes = inflight[url].successes; - delete inflight[url]; - forEach(successes, function(success) { - try { - (success||noop)(status, copy(response)); - } catch(e) { - $log.error(e); - } - }); - }, - function(status, response) { - var errors = inflight[url].errors, - successes = inflight[url].successes; - delete inflight[url]; - - forEach(errors, function(error, i) { - try { - if (isFunction(error)) { - error(status, copy(response)); - } else { - $error( - {method: method, url: url, data: post, success: successes[i]}, - {status: status, body: response}); - } - } catch(e) { - $log.error(e); - } - }); - }); - } - - } else { - cache.data = {}; - cache.delegate(method, url, post, success, error); - } - } - cache.data = {}; - cache.delegate = $xhr; - return cache; - }]; - -} diff --git a/src/service/xhr.error.js b/src/service/xhr.error.js deleted file mode 100644 index 372f97f46888..000000000000 --- a/src/service/xhr.error.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; - -/** - * @ngdoc object - * @name angular.module.ng.$xhr.error - * @function - * @requires $log - * - * @description - * Error handler for {@link angular.module.ng.$xhr $xhr service}. An application can replaces this - * service with one specific for the application. The default implementation logs the error to - * {@link angular.module.ng.$log $log.error}. - * - * @param {Object} request Request object. - * - * The object has the following properties - * - * - `method` – `{string}` – The http request method. - * - `url` – `{string}` – The request destination. - * - `data` – `{(string|Object)=} – An optional request body. - * - `success` – `{function()}` – The success callback function - * - * @param {Object} response Response object. - * - * The response object has the following properties: - * - * - status – {number} – Http status code. - * - body – {string|Object} – Body of the response. - * - * @example - - - fetch a non-existent file and log an error in the console: - - - - */ -function $XhrErrorProvider() { - this.$get = ['$log', function($log) { - return function(request, response){ - $log.error('ERROR: XHR: ' + request.url, request, response); - }; - }]; -} diff --git a/src/service/xhr.js b/src/service/xhr.js deleted file mode 100644 index e9421caf3245..000000000000 --- a/src/service/xhr.js +++ /dev/null @@ -1,231 +0,0 @@ -'use strict'; - -/** - * @ngdoc object - * @name angular.module.ng.$xhr - * @function - * @requires $browser $xhr delegates all XHR requests to the `$browser.xhr()`. A mock version - * of the $browser exists which allows setting expectations on XHR requests - * in your tests - * @requires $xhr.error $xhr delegates all non `2xx` response code to this service. - * @requires $log $xhr delegates all exceptions to `$log.error()`. - * - * @description - * Generates an XHR request. The $xhr service delegates all requests to - * {@link angular.module.ng.$browser $browser.xhr()} and adds error handling and security features. - * While $xhr service provides nicer api than raw XmlHttpRequest, it is still considered a lower - * level api in angular. For a higher level abstraction that utilizes `$xhr`, please check out the - * {@link angular.module.ng.$resource $resource} service. - * - * # Error handling - * If no `error callback` is specified, XHR response with response code other then `2xx` will be - * delegated to {@link angular.module.ng.$xhr.error $xhr.error}. The `$xhr.error` can intercept the - * request and process it in application specific way, or resume normal execution by calling the - * request `success` method. - * - * # HTTP Headers - * The $xhr service will automatically add certain http headers to all requests. These defaults can - * be fully configured by accessing the `$xhr.defaults.headers` configuration object, which - * currently contains this default configuration: - * - * - `$xhr.defaults.headers.common` (headers that are common for all requests): - * - `Accept: application/json, text/plain, *\/*` - * - `X-Requested-With: XMLHttpRequest` - * - `$xhr.defaults.headers.post` (header defaults for HTTP POST requests): - * - `Content-Type: application/x-www-form-urlencoded` - * - * To add or overwrite these defaults, simple add or remove a property from this configuration - * object. To add headers for an HTTP method other than POST, simple create a new object with name - * equal to the lowercased http method name, e.g. `$xhr.defaults.headers.get['My-Header']='value'`. - * - * - * # Security Considerations - * When designing web applications your design needs to consider security threats from - * {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx - * JSON Vulnerability} and {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF}. - * Both server and the client must cooperate in order to eliminate these threats. Angular comes - * pre-configured with strategies that address these issues, but for this to work backend server - * cooperation is required. - * - * ## JSON Vulnerability Protection - * A {@link http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx - * JSON Vulnerability} allows third party web-site to turn your JSON resource URL into - * {@link http://en.wikipedia.org/wiki/JSON#JSONP JSONP} request under some conditions. To - * counter this your server can prefix all JSON requests with following string `")]}',\n"`. - * Angular will automatically strip the prefix before processing it as JSON. - * - * For example if your server needs to return: - *
    - * ['one','two']
    - * 
    - * - * which is vulnerable to attack, your server can return: - *
    - * )]}',
    - * ['one','two']
    - * 
    - * - * angular will strip the prefix, before processing the JSON. - * - * - * ## Cross Site Request Forgery (XSRF) Protection - * {@link http://en.wikipedia.org/wiki/Cross-site_request_forgery XSRF} is a technique by which an - * unauthorized site can gain your user's private data. Angular provides following mechanism to - * counter XSRF. When performing XHR requests, the $xhr service reads a token from a cookie - * called `XSRF-TOKEN` and sets it as the HTTP header `X-XSRF-TOKEN`. Since only JavaScript that - * runs on your domain could read the cookie, your server can be assured that the XHR came from - * JavaScript running on your domain. - * - * To take advantage of this, your server needs to set a token in a JavaScript readable session - * cookie called `XSRF-TOKEN` on first HTTP GET request. On subsequent non-GET requests the server - * can verify that the cookie matches `X-XSRF-TOKEN` HTTP header, and therefore be sure that only - * JavaScript running on your domain could have read the token. The token must be unique for each - * user and must be verifiable by the server (to prevent the JavaScript making up its own tokens). - * We recommend that the token is a digest of your site's authentication cookie with - * {@link http://en.wikipedia.org/wiki/Rainbow_table salt for added security}. - * - * @param {string} method HTTP method to use. Valid values are: `GET`, `POST`, `PUT`, `DELETE`, and - * `JSONP`. `JSONP` is a special case which causes a - * [JSONP](http://en.wikipedia.org/wiki/JSON#JSONP) cross domain request using script tag - * insertion. - * @param {string} url Relative or absolute URL specifying the destination of the request. For - * `JSON` requests, `url` should include `JSON_CALLBACK` string to be replaced with a name of an - * angular generated callback function. - * @param {(string|Object)=} post Request content as either a string or an object to be stringified - * as JSON before sent to the server. - * @param {function(number, (string|Object))} success A function to be called when the response is - * received. The success function will be called with: - * - * - {number} code [HTTP status code](http://en.wikipedia.org/wiki/List_of_HTTP_status_codes) of - * the response. This will currently always be 200, since all non-200 responses are routed to - * {@link angular.module.ng.$xhr.error} service (or custom error callback). - * - {string|Object} response Response object as string or an Object if the response was in JSON - * format. - * @param {function(number, (string|Object))} error A function to be called if the response code is - * not 2xx.. Accepts the same arguments as success, above. - * - * @example - - - -
    - - -
    - - - -
    code={{code}}
    -
    response={{response}}
    -
    -
    - - it('should make xhr GET request', function() { - element(':button:contains("Sample GET")').click(); - element(':button:contains("fetch")').click(); - expect(binding('code')).toBe('code=200'); - expect(binding('response')).toMatch(/angularjs.org/); - }); - - it('should make JSONP request to the angularjs.org', function() { - element(':button:contains("Sample JSONP")').click(); - element(':button:contains("fetch")').click(); - expect(binding('code')).toBe('code=200'); - expect(binding('response')).toMatch(/Super Hero!/); - }); - - it('should make JSONP request to invalid URL and invoke the error handler', - function() { - element(':button:contains("Invalid JSONP")').click(); - element(':button:contains("fetch")').click(); - expect(binding('code')).toBe('code=-2'); - expect(binding('response')).toBe('response=Request failed'); - }); - -
    - */ -function $XhrProvider() { - this.$get = ['$rootScope', '$browser', '$xhr.error', '$log', - function( $rootScope, $browser, $error, $log){ - var xhrHeaderDefaults = { - common: { - "Accept": "application/json, text/plain, */*", - "X-Requested-With": "XMLHttpRequest" - }, - post: {'Content-Type': 'application/x-www-form-urlencoded'}, - get: {}, // all these empty properties are needed so that client apps can just do: - head: {}, // $xhr.defaults.headers.head.foo="bar" without having to create head object - put: {}, // it also means that if we add a header for these methods in the future, it - 'delete': {}, // won't be easily silently lost due to an object assignment. - patch: {} - }; - - function xhr(method, url, post, success, error) { - if (isFunction(post)) { - error = success; - success = post; - post = null; - } - if (post && isObject(post)) { - post = toJson(post); - } - - $browser.xhr(method, url, post, function(code, response){ - try { - if (isString(response)) { - if (response.match(/^\)\]\}',\n/)) response=response.substr(6); - if (/^\s*[\[\{]/.exec(response) && /[\}\]]\s*$/.exec(response)) { - response = fromJson(response, true); - } - } - $rootScope.$apply(function() { - if (200 <= code && code < 300) { - success(code, response); - } else if (isFunction(error)) { - error(code, response); - } else { - $error( - {method: method, url: url, data: post, success: success}, - {status: code, body: response}); - } - }); - } catch (e) { - $log.error(e); - } - }, extend({'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN']}, - xhrHeaderDefaults.common, - xhrHeaderDefaults[lowercase(method)])); - } - - xhr.defaults = {headers: xhrHeaderDefaults}; - - return xhr; - }]; -} diff --git a/src/widgets.js b/src/widgets.js index c4494b293f9b..419763ca78e3 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -90,12 +90,14 @@ angularWidget('ng:include', function(element){ this.directives(true); } else { element[0]['ng:compiled'] = true; - return ['$xhr.cache', '$element', function(xhr, element){ + return ['$http', '$cacheFactory', '$element', function($http, $cacheFactory, element) { var scope = this, changeCounter = 0, releaseScopes = [], childScope, - oldScope; + oldScope, + // TODO(vojta): configure the cache / extract into $tplCache service ? + cache = $cacheFactory.get('templates') || $cacheFactory('templates'); function incrementChange() { changeCounter++;} this.$watch(srcExp, incrementChange); @@ -108,25 +110,41 @@ angularWidget('ng:include', function(element){ }); this.$watch(function() {return changeCounter;}, function(scope) { var src = scope.$eval(srcExp), - useScope = scope.$eval(scopeExp); + useScope = scope.$eval(scopeExp), + fromCache; + + function updateContent(content) { + element.html(content); + if (useScope) { + childScope = useScope; + } else { + releaseScopes.push(childScope = scope.$new()); + } + compiler.compile(element)(childScope); + scope.$eval(onloadExp); + } + + function clearContent() { + childScope = null; + element.html(''); + } while(releaseScopes.length) { releaseScopes.pop().$destroy(); } if (src) { - xhr('GET', src, null, function(code, response){ - element.html(response); - if (useScope) { - childScope = useScope; - } else { - releaseScopes.push(childScope = scope.$new()); - } - compiler.compile(element)(childScope); - scope.$eval(onloadExp); - }, false, true); + if ((fromCache = cache.get(src))) { + scope.$evalAsync(function() { + updateContent(fromCache); + }); + } else { + $http.get(src).on('success', function(response) { + updateContent(response); + cache.put(src, response); + }).on('error', clearContent); + } } else { - childScope = null; - element.html(''); + clearContent(); } }); }]; @@ -555,27 +573,46 @@ angularWidget('ng:view', function(element) { if (!element[0]['ng:compiled']) { element[0]['ng:compiled'] = true; - return ['$xhr.cache', '$route', '$element', function($xhr, $route, element){ + return ['$http', '$cacheFactory', '$route', '$element', function($http, $cacheFactory, $route, element) { var template; var changeCounter = 0; + // TODO(vojta): configure the cache / extract into $tplCache service ? + var cache = $cacheFactory.get('templates') || $cacheFactory('templates'); + this.$on('$afterRouteChange', function() { changeCounter++; }); this.$watch(function() {return changeCounter;}, function(scope, newChangeCounter) { - var template = $route.current && $route.current.template; + var template = $route.current && $route.current.template, + fromCache; + + function updateContent(content) { + element.html(content); + compiler.compile(element)($route.current.scope); + } + + function clearContent() { + element.html(''); + } + if (template) { - //xhr's callback must be async, see commit history for more info - $xhr('GET', template, function(code, response) { - // ignore callback if another route change occured since - if (newChangeCounter == changeCounter) { - element.html(response); - compiler.compile(element)($route.current.scope); - } - }); + if ((fromCache = cache.get(template))) { + scope.$evalAsync(function() { + updateContent(fromCache); + }); + } else { + // xhr's callback must be async, see commit history for more info + $http.get(template).on('success', function(response) { + // ignore callback if another route change occured since + if (newChangeCounter == changeCounter) + updateContent(response); + cache.put(template, response); + }).on('error', clearContent); + } } else { - element.html(''); + clearContent(); } }); }]; diff --git a/test/ResourceSpec.js b/test/ResourceSpec.js index 57aaffe0b265..46616799d7ab 100644 --- a/test/ResourceSpec.js +++ b/test/ResourceSpec.js @@ -1,6 +1,6 @@ 'use strict'; -describe("resource", function() { +xdescribe("resource", function() { var resource, CreditCard, callback; function nakedExpect(obj) { diff --git a/test/directivesSpec.js b/test/directivesSpec.js index 5f9fa0a86015..ffb6d57c2b9f 100644 --- a/test/directivesSpec.js +++ b/test/directivesSpec.js @@ -502,12 +502,12 @@ describe("directive", function() { expect(element.text()).toEqual('hey dude!'); })); - it('should infer injection arguments', inject(function($rootScope, $compile, $xhr) { - temp.MyController = function($xhr){ - this.$root.someService = $xhr; + it('should infer injection arguments', inject(function($rootScope, $compile, $http) { + temp.MyController = function($http) { + this.$root.someService = $http; }; var element = $compile('
    ')($rootScope); - expect($rootScope.someService).toBe($xhr); + expect($rootScope.someService).toBe($http); })); }); diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index 566ffb09727f..2ec000f46412 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -124,7 +124,7 @@ describe('browser', function() { // We don't have unit tests for IE because script.readyState is readOnly. - // Instead we run e2e tests on all browsers - see e2e for $xhr. + // Instead we run e2e tests on all browsers - see e2e for $http. if (!msie) { it('should add script tag for JSONP request', function() { diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js new file mode 100644 index 000000000000..196a57edacc4 --- /dev/null +++ b/test/service/httpSpec.js @@ -0,0 +1,983 @@ +'use strict'; + +// TODO(vojta): refactor these tests to use new inject() syntax +describe('$http', function() { + + var $http, $browser, $exceptionHandler, // services + method, url, data, headers, timeout, // passed arguments + onSuccess, onError, // callback spies + scope, errorLogs, respond, rawXhrObject, future; + + beforeEach(inject(function($injector) { + $injector.get('$exceptionHandlerProvider').mode('log'); + scope = $injector.get('$rootScope'); + $http = $injector.get('$http'); + $browser = $injector.get('$browser'); + $exceptionHandler = $injector.get('$exceptionHandler'); + + // TODO(vojta): move this into mock browser ? + respond = method = url = data = headers = null; + rawXhrObject = { + abort: jasmine.createSpy('request.abort'), + getResponseHeader: function(h) {return h + '-val';}, + getAllResponseHeaders: function() { + return 'content-encoding: gzip\nserver: Apache\n'; + } + }; + + spyOn(scope, '$apply'); + spyOn($browser, 'xhr').andCallFake(function(m, u, d, c, h, t) { + method = m; + url = u; + data = d; + respond = c; + headers = h; + timeout = t; + return rawXhrObject; + }); + })); + + afterEach(function() { + // expect($exceptionHandler.errors.length).toBe(0); + }); + + function doCommonXhr(method, url) { + future = $http({method: method || 'GET', url: url || '/url'}); + + onSuccess = jasmine.createSpy('on200'); + onError = jasmine.createSpy('on400'); + future.on('200', onSuccess); + future.on('400', onError); + + return future; + } + + + it('should do basic request', function() { + $http({url: '/url', method: 'GET'}); + expect($browser.xhr).toHaveBeenCalledOnce(); + expect(url).toBe('/url'); + expect(method).toBe('GET'); + }); + + + it('should pass data if specified', function() { + $http({url: '/url', method: 'POST', data: 'some-data'}); + expect($browser.xhr).toHaveBeenCalledOnce(); + expect(data).toBe('some-data'); + }); + + + it('should pass timeout if specified', function() { + $http({url: '/url', method: 'POST', timeout: 5000}); + expect($browser.xhr).toHaveBeenCalledOnce(); + expect(timeout).toBe(5000); + }); + + + describe('callbacks', function() { + + beforeEach(doCommonXhr); + + it('should log exceptions', function() { + onSuccess.andThrow('exception in success callback'); + onError.andThrow('exception in error callback'); + + respond(200, 'content'); + expect($exceptionHandler.errors.pop()).toContain('exception in success callback'); + + respond(400, ''); + expect($exceptionHandler.errors.pop()).toContain('exception in error callback'); + }); + + + it('should log more exceptions', function() { + onError.andThrow('exception in error callback'); + future.on('500', onError).on('50x', onError); + respond(500, ''); + + expect($exceptionHandler.errors.length).toBe(2); + $exceptionHandler.errors = []; + }); + + + it('should get response as first param', function() { + respond(200, 'response'); + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onSuccess.mostRecentCall.args[0]).toBe('response'); + + respond(400, 'empty'); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mostRecentCall.args[0]).toBe('empty'); + }); + + + it('should get status code as second param', function() { + respond(200, 'response'); + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onSuccess.mostRecentCall.args[1]).toBe(200); + + respond(400, 'empty'); + expect(onError).toHaveBeenCalledOnce(); + expect(onError.mostRecentCall.args[1]).toBe(400); + }); + }); + + + describe('response headers', function() { + + var callback; + + beforeEach(function() { + callback = jasmine.createSpy('callback'); + }); + + it('should return single header', function() { + callback.andCallFake(function(r, s, header) { + expect(header('date')).toBe('date-val'); + }); + + $http({url: '/url', method: 'GET'}).on('200', callback); + respond(200, ''); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should return null when single header does not exist', function() { + callback.andCallFake(function(r, s, header) { + header(); // we need that to get headers parsed first + expect(header('nothing')).toBe(null); + }); + + $http({url: '/url', method: 'GET'}).on('200', callback); + respond(200, ''); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should return all headers as object', function() { + callback.andCallFake(function(r, s, header) { + expect(header()).toEqual({'content-encoding': 'gzip', 'server': 'Apache'}); + }); + + $http({url: '/url', method: 'GET'}).on('200', callback); + respond(200, ''); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should return empty object for jsonp request', function() { + // jsonp doesn't return raw object + rawXhrObject = undefined; + callback.andCallFake(function(r, s, headers) { + expect(headers()).toEqual({}); + }); + + $http({url: '/some', method: 'JSONP'}).on('200', callback); + respond(200, ''); + expect(callback).toHaveBeenCalledOnce(); + }); + }); + + + describe('response headers parser', function() { + + it('should parse basic', function() { + var parsed = parseHeaders( + 'date: Thu, 04 Aug 2011 20:23:08 GMT\n' + + 'content-encoding: gzip\n' + + 'transfer-encoding: chunked\n' + + 'x-cache-info: not cacheable; response has already expired, not cacheable; response has already expired\n' + + 'connection: Keep-Alive\n' + + 'x-backend-server: pm-dekiwiki03\n' + + 'pragma: no-cache\n' + + 'server: Apache\n' + + 'x-frame-options: DENY\n' + + 'content-type: text/html; charset=utf-8\n' + + 'vary: Cookie, Accept-Encoding\n' + + 'keep-alive: timeout=5, max=1000\n' + + 'expires: Thu: , 19 Nov 1981 08:52:00 GMT\n'); + + expect(parsed['date']).toBe('Thu, 04 Aug 2011 20:23:08 GMT'); + expect(parsed['content-encoding']).toBe('gzip'); + expect(parsed['transfer-encoding']).toBe('chunked'); + expect(parsed['keep-alive']).toBe('timeout=5, max=1000'); + }); + + + it('should parse lines without space after colon', function() { + expect(parseHeaders('key:value').key).toBe('value'); + }); + + + it('should trim the values', function() { + expect(parseHeaders('key: value ').key).toBe('value'); + }); + + + it('should allow headers without value', function() { + expect(parseHeaders('key:').key).toBe(''); + }); + + + it('should merge headers with same key', function() { + expect(parseHeaders('key: a\nkey:b\n').key).toBe('a, b'); + }); + + + it('should normalize keys to lower case', function() { + expect(parseHeaders('KeY: value').key).toBe('value'); + }); + + + it('should parse CRLF as delimiter', function() { + // IE does use CRLF + expect(parseHeaders('a: b\r\nc: d\r\n')).toEqual({a: 'b', c: 'd'}); + expect(parseHeaders('a: b\r\nc: d\r\n').a).toBe('b'); + }); + + + it('should parse tab after semi-colon', function() { + expect(parseHeaders('a:\tbb').a).toBe('bb'); + expect(parseHeaders('a: \tbb').a).toBe('bb'); + }); + }); + + + describe('request headers', function() { + + it('should send custom headers', function() { + $http({url: '/url', method: 'GET', headers: { + 'Custom': 'header', + 'Content-Type': 'application/json' + }}); + + expect(headers['Custom']).toEqual('header'); + expect(headers['Content-Type']).toEqual('application/json'); + }); + + + it('should set default headers for GET request', function() { + $http({url: '/url', method: 'GET', headers: {}}); + + expect(headers['Accept']).toBe('application/json, text/plain, */*'); + expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + }); + + + it('should set default headers for POST request', function() { + $http({url: '/url', method: 'POST', headers: {}}); + + expect(headers['Accept']).toBe('application/json, text/plain, */*'); + expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + expect(headers['Content-Type']).toBe('application/json'); + }); + + + it('should set default headers for PUT request', function() { + $http({url: '/url', method: 'PUT', headers: {}}); + + expect(headers['Accept']).toBe('application/json, text/plain, */*'); + expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + expect(headers['Content-Type']).toBe('application/json'); + }); + + + it('should set default headers for custom HTTP method', function() { + $http({url: '/url', method: 'FOO', headers: {}}); + + expect(headers['Accept']).toBe('application/json, text/plain, */*'); + expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + }); + + + it('should override default headers with custom', function() { + $http({url: '/url', method: 'POST', headers: { + 'Accept': 'Rewritten', + 'Content-Type': 'Rewritten' + }}); + + expect(headers['Accept']).toBe('Rewritten'); + expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + expect(headers['Content-Type']).toBe('Rewritten'); + }); + + + it('should set the XSRF cookie into a XSRF header', function() { + $browser.cookies('XSRF-TOKEN', 'secret'); + + $http({url: '/url', method: 'GET'}); + expect(headers['X-XSRF-TOKEN']).toBe('secret'); + + $http({url: '/url', method: 'POST', headers: {'S-ome': 'Header'}}); + expect(headers['X-XSRF-TOKEN']).toBe('secret'); + + $http({url: '/url', method: 'PUT', headers: {'Another': 'Header'}}); + expect(headers['X-XSRF-TOKEN']).toBe('secret'); + + $http({url: '/url', method: 'DELETE', headers: {}}); + expect(headers['X-XSRF-TOKEN']).toBe('secret'); + }); + }); + + + describe('short methods', function() { + + it('should have .get()', function() { + $http.get('/url'); + + expect(method).toBe('GET'); + expect(url).toBe('/url'); + }); + + + it('.get() should allow config param', function() { + $http.get('/url', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('GET'); + expect(url).toBe('/url'); + expect(headers['Custom']).toBe('Header'); + }); + + + it('should have .delete()', function() { + $http['delete']('/url'); + + expect(method).toBe('DELETE'); + expect(url).toBe('/url'); + }); + + + it('.delete() should allow config param', function() { + $http['delete']('/url', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('DELETE'); + expect(url).toBe('/url'); + expect(headers['Custom']).toBe('Header'); + }); + + + it('should have .head()', function() { + $http.head('/url'); + + expect(method).toBe('HEAD'); + expect(url).toBe('/url'); + }); + + + it('.head() should allow config param', function() { + $http.head('/url', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('HEAD'); + expect(url).toBe('/url'); + expect(headers['Custom']).toBe('Header'); + }); + + + it('should have .patch()', function() { + $http.patch('/url'); + + expect(method).toBe('PATCH'); + expect(url).toBe('/url'); + }); + + + it('.patch() should allow config param', function() { + $http.patch('/url', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('PATCH'); + expect(url).toBe('/url'); + expect(headers['Custom']).toBe('Header'); + }); + + + it('should have .post()', function() { + $http.post('/url', 'some-data'); + + expect(method).toBe('POST'); + expect(url).toBe('/url'); + expect(data).toBe('some-data'); + }); + + + it('.post() should allow config param', function() { + $http.post('/url', 'some-data', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('POST'); + expect(url).toBe('/url'); + expect(data).toBe('some-data'); + expect(headers['Custom']).toBe('Header'); + }); + + + it('should have .put()', function() { + $http.put('/url', 'some-data'); + + expect(method).toBe('PUT'); + expect(url).toBe('/url'); + expect(data).toBe('some-data'); + }); + + + it('.put() should allow config param', function() { + $http.put('/url', 'some-data', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('PUT'); + expect(url).toBe('/url'); + expect(data).toBe('some-data'); + expect(headers['Custom']).toBe('Header'); + }); + + + it('should have .jsonp()', function() { + $http.jsonp('/url'); + + expect(method).toBe('JSONP'); + expect(url).toBe('/url'); + }); + + + it('.jsonp() should allow config param', function() { + $http.jsonp('/url', {headers: {'Custom': 'Header'}}); + + expect(method).toBe('JSONP'); + expect(url).toBe('/url'); + expect(headers['Custom']).toBe('Header'); + }); + }); + + + describe('future', function() { + + describe('abort', function() { + + beforeEach(doCommonXhr); + + it('should return itself to allow chaining', function() { + expect(future.abort()).toBe(future); + }); + + it('should allow aborting the request', function() { + future.abort(); + + expect(rawXhrObject.abort).toHaveBeenCalledOnce(); + }); + + + it('should not abort already finished request', function() { + respond(200, 'content'); + + future.abort(); + expect(rawXhrObject.abort).not.toHaveBeenCalled(); + }); + }); + + + describe('retry', function() { + + it('should retry last request with same callbacks', function() { + doCommonXhr('HEAD', '/url-x'); + respond(200, ''); + $browser.xhr.reset(); + onSuccess.reset(); + + future.retry(); + expect($browser.xhr).toHaveBeenCalledOnce(); + expect(method).toBe('HEAD'); + expect(url).toBe('/url-x'); + + respond(200, 'body'); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + + it('should return itself to allow chaining', function() { + doCommonXhr(); + respond(200, ''); + expect(future.retry()).toBe(future); + }); + + + it('should throw error when pending request', function() { + doCommonXhr(); + expect(future.retry).toThrow('Can not retry request. Abort pending request first.'); + }); + }); + + + describe('on', function() { + + var callback; + + beforeEach(function() { + future = $http({method: 'GET', url: '/url'}); + callback = jasmine.createSpy('callback'); + }); + + it('should return itself to allow chaining', function() { + expect(future.on('200', noop)).toBe(future); + }); + + + it('should call exact status code callback', function() { + future.on('205', callback); + respond(205, ''); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should match 2xx', function() { + future.on('2xx', callback); + + respond(200, ''); + respond(201, ''); + respond(266, ''); + + respond(400, ''); + respond(300, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(3); + }); + + + it('should match 20x', function() { + future.on('20x', callback); + + respond(200, ''); + respond(201, ''); + respond(205, ''); + + respond(400, ''); + respond(300, ''); + respond(210, ''); + respond(255, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(3); + }); + + + it('should match 2x1', function() { + future.on('2x1', callback); + + respond(201, ''); + respond(211, ''); + respond(251, ''); + + respond(400, ''); + respond(300, ''); + respond(210, ''); + respond(255, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(3); + }); + + + it('should match xxx', function() { + future.on('xxx', callback); + + respond(201, ''); + respond(211, ''); + respond(251, ''); + respond(404, ''); + respond(501, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(5); + }); + + + it('should call all matched callbacks', function() { + var no = jasmine.createSpy('wrong'); + future.on('xxx', callback); + future.on('2xx', callback); + future.on('205', callback); + future.on('3xx', no); + future.on('2x1', no); + future.on('4xx', no); + respond(205, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(3); + expect(no).not.toHaveBeenCalled(); + }); + + + it('should allow list of status patterns', function() { + future.on('2xx,3xx', callback); + + respond(405, ''); + expect(callback).not.toHaveBeenCalled(); + + respond(201); + expect(callback).toHaveBeenCalledOnce(); + + respond(301); + expect(callback.callCount).toBe(2); + }); + + + it('should preserve the order of listeners', function() { + var log = ''; + future.on('2xx', function() {log += '1';}); + future.on('201', function() {log += '2';}); + future.on('2xx', function() {log += '3';}); + + respond(201); + expect(log).toBe('123'); + }); + + + it('should know "success" alias', function() { + future.on('success', callback); + respond(200, ''); + expect(callback).toHaveBeenCalledOnce(); + + callback.reset(); + respond(201, ''); + expect(callback).toHaveBeenCalledOnce(); + + callback.reset(); + respond(250, ''); + expect(callback).toHaveBeenCalledOnce(); + + callback.reset(); + respond(404, ''); + respond(501, ''); + expect(callback).not.toHaveBeenCalled(); + }); + + + it('should know "error" alias', function() { + future.on('error', callback); + respond(401, ''); + expect(callback).toHaveBeenCalledOnce(); + + callback.reset(); + respond(500, ''); + expect(callback).toHaveBeenCalledOnce(); + + callback.reset(); + respond(0, ''); + expect(callback).toHaveBeenCalledOnce(); + + callback.reset(); + respond(201, ''); + respond(200, ''); + respond(300, ''); + expect(callback).not.toHaveBeenCalled(); + }); + + + it('should know "always" alias', function() { + future.on('always', callback); + respond(201, ''); + respond(200, ''); + respond(300, ''); + respond(401, ''); + respond(502, ''); + respond(0, ''); + respond(-1, ''); + respond(-2, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(8); + }); + + + it('should call "xxx" when 0 status code', function() { + future.on('xxx', callback); + respond(0, ''); + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should not call "2xx" when 0 status code', function() { + future.on('2xx', callback); + respond(0, ''); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should normalize internal statuses -1, -2 to 0', function() { + callback.andCallFake(function(response, status) { + expect(status).toBe(0); + }); + + future.on('xxx', callback); + respond(-1, ''); + respond(-2, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(2); + }); + + it('should match "timeout" when -1 internal status', function() { + future.on('timeout', callback); + respond(-1, ''); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should match "abort" when 0 status', function() { + future.on('abort', callback); + respond(0, ''); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should match "error" when 0, -1, or -2', function() { + future.on('error', callback); + respond(0, ''); + respond(-1, ''); + respond(-2, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(3); + }); + }); + }); + + + describe('scope.$apply', function() { + + beforeEach(doCommonXhr); + + it('should $apply after success callback', function() { + respond(200, ''); + expect(scope.$apply).toHaveBeenCalledOnce(); + }); + + + it('should $apply after error callback', function() { + respond(404, ''); + expect(scope.$apply).toHaveBeenCalledOnce(); + }); + + + it('should $apply even if exception thrown during callback', function() { + onSuccess.andThrow('error in callback'); + onError.andThrow('error in callback'); + + respond(200, ''); + expect(scope.$apply).toHaveBeenCalledOnce(); + + scope.$apply.reset(); + respond(400, ''); + expect(scope.$apply).toHaveBeenCalledOnce(); + + $exceptionHandler.errors = []; + }); + }); + + + describe('transform', function() { + + describe('request', function() { + + describe('default', function() { + + it('should transform object into json', function() { + $http({method: 'POST', url: '/url', data: {one: 'two'}}); + expect(data).toBe('{"one":"two"}'); + }); + + + it('should ignore strings', function() { + $http({method: 'POST', url: '/url', data: 'string-data'}); + expect(data).toBe('string-data'); + }); + }); + }); + + + describe('response', function() { + + describe('default', function() { + + it('should deserialize json objects', function() { + doCommonXhr(); + respond(200, '{"foo":"bar","baz":23}'); + + expect(onSuccess.mostRecentCall.args[0]).toEqual({foo: 'bar', baz: 23}); + }); + + + it('should deserialize json arrays', function() { + doCommonXhr(); + respond(200, '[1, "abc", {"foo":"bar"}]'); + + expect(onSuccess.mostRecentCall.args[0]).toEqual([1, 'abc', {foo: 'bar'}]); + }); + + + it('should deserialize json with security prefix', function() { + doCommonXhr(); + respond(200, ')]}\',\n[1, "abc", {"foo":"bar"}]'); + + expect(onSuccess.mostRecentCall.args[0]).toEqual([1, 'abc', {foo:'bar'}]); + }); + }); + + it('should pipeline more functions', function() { + function first(d) {return d + '1';} + function second(d) {return d + '2';} + onSuccess = jasmine.createSpy('onSuccess'); + + $http({method: 'POST', url: '/url', data: '0', transformResponse: [first, second]}) + .on('200', onSuccess); + + respond(200, '0'); + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onSuccess.mostRecentCall.args[0]).toBe('012'); + }); + }); + }); + + + describe('cache', function() { + + function doFirstCacheRequest(method, responseStatus) { + onSuccess = jasmine.createSpy('on200'); + $http({method: method || 'get', url: '/url', cache: true}); + respond(responseStatus || 200, 'content'); + $browser.xhr.reset(); + } + + it('should cache GET request', function() { + doFirstCacheRequest(); + + $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); + $browser.defer.flush(); + + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onSuccess.mostRecentCall.args[0]).toBe('content'); + expect($browser.xhr).not.toHaveBeenCalled(); + }); + + + it('should always call callback asynchronously', function() { + doFirstCacheRequest(); + + $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + + it('should not cache POST request', function() { + doFirstCacheRequest('post'); + + $http({method: 'post', url: '/url', cache: true}).on('200', onSuccess); + $browser.defer.flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect($browser.xhr).toHaveBeenCalledOnce(); + }); + + + it('should not cache PUT request', function() { + doFirstCacheRequest('put'); + + $http({method: 'put', url: '/url', cache: true}).on('200', onSuccess); + $browser.defer.flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect($browser.xhr).toHaveBeenCalledOnce(); + }); + + + it('should not cache DELETE request', function() { + doFirstCacheRequest('delete'); + + $http({method: 'delete', url: '/url', cache: true}).on('200', onSuccess); + $browser.defer.flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect($browser.xhr).toHaveBeenCalledOnce(); + }); + + + it('should not cache non 2xx responses', function() { + doFirstCacheRequest('get', 404); + + $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); + $browser.defer.flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect($browser.xhr).toHaveBeenCalledOnce(); + }); + + + it('should cache the headers as well', function() { + doFirstCacheRequest(); + onSuccess.andCallFake(function(r, s, headers) { + expect(headers()).toEqual({'content-encoding': 'gzip', 'server': 'Apache'}); + expect(headers('server')).toBe('Apache'); + }); + + $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); + $browser.defer.flush(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + + it('should cache status code as well', function() { + doFirstCacheRequest('get', 201); + onSuccess.andCallFake(function(r, status, h) { + expect(status).toBe(201); + }); + + $http({method: 'get', url: '/url', cache: true}).on('2xx', onSuccess); + $browser.defer.flush(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + }); + + + describe('pendingCount', function() { + + it('should return number of pending requests', function() { + expect($http.pendingCount()).toBe(0); + + $http({method: 'get', url: '/some'}); + expect($http.pendingCount()).toBe(1); + + respond(200, ''); + expect($http.pendingCount()).toBe(0); + }); + + + it('should decrement the counter when request aborted', function() { + future = $http({method: 'get', url: '/x'}); + expect($http.pendingCount()).toBe(1); + future.abort(); + respond(0, ''); + + expect($http.pendingCount()).toBe(0); + }); + + + it('should decrement the counter when served from cache', function() { + $http({method: 'get', url: '/cached', cache: true}); + respond(200, 'content'); + expect($http.pendingCount()).toBe(0); + + $http({method: 'get', url: '/cached', cache: true}); + expect($http.pendingCount()).toBe(1); + + $browser.defer.flush(); + expect($http.pendingCount()).toBe(0); + }); + + + it('should decrement the counter before firing callbacks', function() { + $http({method: 'get', url: '/cached'}).on('xxx', function() { + expect($http.pendingCount()).toBe(0); + }); + + expect($http.pendingCount()).toBe(1); + respond(200, 'content'); + }); + }); +}); diff --git a/test/service/xhr.bulkSpec.js b/test/service/xhr.bulkSpec.js deleted file mode 100644 index 6e55b3877dd2..000000000000 --- a/test/service/xhr.bulkSpec.js +++ /dev/null @@ -1,81 +0,0 @@ -'use strict'; - -describe('$xhr.bulk', function() { - var log; - - beforeEach(inject(function($provide) { - $provide.value('$xhr.error', jasmine.createSpy('$xhr.error')); - $provide.factory('$xhrError', ['$xhr.error', identity]); - $provide.factory('$xhrBulk', ['$xhr.bulk', identity]); - log = ''; - })); - - - function callback(code, response) { - expect(code).toEqual(200); - log = log + toJson(response) + ';'; - } - - - it('should collect requests', inject(function($browser, $xhrBulk) { - $xhrBulk.urls["/"] = {match:/.*/}; - $xhrBulk('GET', '/req1', null, callback); - $xhrBulk('POST', '/req2', {post:'data'}, callback); - - $browser.xhr.expectPOST('/', { - requests:[{method:'GET', url:'/req1', data: null}, - {method:'POST', url:'/req2', data:{post:'data'} }] - }).respond([ - {status:200, response:'first'}, - {status:200, response:'second'} - ]); - $xhrBulk.flush(function() { log += 'DONE';}); - $browser.xhr.flush(); - expect(log).toEqual('"first";"second";DONE'); - })); - - - it('should handle non 200 status code by forwarding to error handler', - inject(function($browser, $xhrBulk, $xhrError) { - $xhrBulk.urls['/'] = {match:/.*/}; - $xhrBulk('GET', '/req1', null, callback); - $xhrBulk('POST', '/req2', {post:'data'}, callback); - - $browser.xhr.expectPOST('/', { - requests:[{method:'GET', url:'/req1', data: null}, - {method:'POST', url:'/req2', data:{post:'data'} }] - }).respond([ - {status:404, response:'NotFound'}, - {status:200, response:'second'} - ]); - $xhrBulk.flush(function() { log += 'DONE';}); - $browser.xhr.flush(); - - expect($xhrError).toHaveBeenCalled(); - var cb = $xhrError.mostRecentCall.args[0].success; - expect(typeof cb).toEqual('function'); - expect($xhrError).toHaveBeenCalledWith( - {url: '/req1', method: 'GET', data: null, success: cb}, - {status: 404, response: 'NotFound'}); - - expect(log).toEqual('"second";DONE'); - })); - - it('should handle non 200 status code by calling error callback if provided', - inject(function($browser, $xhrBulk, $xhrError) { - var callback = jasmine.createSpy('error'); - - $xhrBulk.urls['/'] = {match: /.*/}; - $xhrBulk('GET', '/req1', null, noop, callback); - - $browser.xhr.expectPOST('/', { - requests:[{method: 'GET', url: '/req1', data: null}] - }).respond([{status: 404, response: 'NotFound'}]); - - $xhrBulk.flush(); - $browser.xhr.flush(); - - expect($xhrError).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(404, 'NotFound'); - })); -}); diff --git a/test/service/xhr.cacheSpec.js b/test/service/xhr.cacheSpec.js deleted file mode 100644 index b6eeb6aa128d..000000000000 --- a/test/service/xhr.cacheSpec.js +++ /dev/null @@ -1,175 +0,0 @@ -'use strict'; - -describe('$xhr.cache', function() { - var log; - - beforeEach(inject(function($provide) { - $provide.value('$xhr.error', jasmine.createSpy('$xhr.error')); - $provide.factory('$xhrError', ['$xhr.error', identity]); - $provide.factory('$xhrBulk', ['$xhr.bulk', identity]); - $provide.factory('$xhrCache', ['$xhr.cache', identity]); - log = ''; - })); - - - function callback(code, response) { - expect(code).toEqual(200); - log = log + toJson(response) + ';'; - } - - - it('should cache requests', inject(function($browser, $xhrCache) { - $browser.xhr.expectGET('/url').respond('first'); - $xhrCache('GET', '/url', null, callback); - $browser.xhr.flush(); - - $browser.xhr.expectGET('/url').respond('ERROR'); - $xhrCache('GET', '/url', null, callback); - $browser.defer.flush(); - expect(log).toEqual('"first";"first";'); - - $xhrCache('GET', '/url', null, callback, false); - $browser.defer.flush(); - expect(log).toEqual('"first";"first";"first";'); - })); - - - it('should first return cache request, then return server request', inject(function($browser, $xhrCache) { - $browser.xhr.expectGET('/url').respond('first'); - $xhrCache('GET', '/url', null, callback, true); - $browser.xhr.flush(); - - $browser.xhr.expectGET('/url').respond('ERROR'); - $xhrCache('GET', '/url', null, callback, true); - $browser.defer.flush(); - expect(log).toEqual('"first";"first";'); - - $browser.xhr.flush(); - expect(log).toEqual('"first";"first";"ERROR";'); - })); - - - it('should serve requests from cache', inject(function($browser, $xhrCache) { - $xhrCache.data.url = {value:'123'}; - $xhrCache('GET', 'url', null, callback); - $browser.defer.flush(); - expect(log).toEqual('"123";'); - - $xhrCache('GET', 'url', null, callback, false); - $browser.defer.flush(); - expect(log).toEqual('"123";"123";'); - })); - - - it('should keep track of in flight requests and request only once', inject(function($browser, $xhrCache, $xhrBulk) { - $xhrBulk.urls['/bulk'] = { - match:function(url){ - return url == '/url'; - } - }; - $browser.xhr.expectPOST('/bulk', { - requests:[{method:'GET', url:'/url', data: null}] - }).respond([ - {status:200, response:'123'} - ]); - $xhrCache('GET', '/url', null, callback); - $xhrCache('GET', '/url', null, callback); - $xhrCache.delegate.flush(); - $browser.xhr.flush(); - expect(log).toEqual('"123";"123";'); - })); - - - it('should clear cache on non GET', inject(function($browser, $xhrCache) { - $browser.xhr.expectPOST('abc', {}).respond({}); - $xhrCache.data.url = {value:123}; - $xhrCache('POST', 'abc', {}); - expect($xhrCache.data.url).toBeUndefined(); - })); - - - it('should call callback asynchronously for both cache hit and cache miss', inject(function($browser, $xhrCache) { - $browser.xhr.expectGET('/url').respond('+'); - $xhrCache('GET', '/url', null, callback); - expect(log).toEqual(''); //callback hasn't executed - - $browser.xhr.flush(); - expect(log).toEqual('"+";'); //callback has executed - - $xhrCache('GET', '/url', null, callback); - expect(log).toEqual('"+";'); //callback hasn't executed - - $browser.defer.flush(); - expect(log).toEqual('"+";"+";'); //callback has executed - })); - - - it('should call callback synchronously when sync flag is on', inject(function($browser, $xhrCache) { - $browser.xhr.expectGET('/url').respond('+'); - $xhrCache('GET', '/url', null, callback, false, true); - expect(log).toEqual(''); //callback hasn't executed - - $browser.xhr.flush(); - expect(log).toEqual('"+";'); //callback has executed - - $xhrCache('GET', '/url', null, callback, false, true); - expect(log).toEqual('"+";"+";'); //callback has executed - - $browser.defer.flush(); - expect(log).toEqual('"+";"+";'); //callback was not called again any more - })); - - - it('should call eval after callbacks for both cache hit and cache miss execute', - inject(function($browser, $xhrCache, $rootScope) { - var flushSpy = this.spyOn($rootScope, '$digest').andCallThrough(); - - $browser.xhr.expectGET('/url').respond('+'); - $xhrCache('GET', '/url', null, callback); - expect(flushSpy).not.toHaveBeenCalled(); - - $browser.xhr.flush(); - expect(flushSpy).toHaveBeenCalled(); - - flushSpy.reset(); //reset the spy - - $xhrCache('GET', '/url', null, callback); - expect(flushSpy).not.toHaveBeenCalled(); - - $browser.defer.flush(); - expect(flushSpy).toHaveBeenCalled(); - })); - - it('should call the error callback on error if provided', inject(function($browser, $xhrCache) { - var errorSpy = jasmine.createSpy('error'), - successSpy = jasmine.createSpy('success'); - - $browser.xhr.expectGET('/url').respond(500, 'error'); - - $xhrCache('GET', '/url', null, successSpy, errorSpy, false, true); - $browser.xhr.flush(); - expect(errorSpy).toHaveBeenCalledWith(500, 'error'); - expect(successSpy).not.toHaveBeenCalled(); - - errorSpy.reset(); - $xhrCache('GET', '/url', successSpy, errorSpy, false, true); - $browser.xhr.flush(); - expect(errorSpy).toHaveBeenCalledWith(500, 'error'); - expect(successSpy).not.toHaveBeenCalled(); - })); - - it('should call the $xhr.error on error if error callback not provided', - inject(function($browser, $xhrCache, $xhrError) { - var errorSpy = jasmine.createSpy('error'), - successSpy = jasmine.createSpy('success'); - - $browser.xhr.expectGET('/url').respond(500, 'error'); - $xhrCache('GET', '/url', null, successSpy, false, true); - $browser.xhr.flush(); - - expect(successSpy).not.toHaveBeenCalled(); - expect($xhrError).toHaveBeenCalledWith( - {method: 'GET', url: '/url', data: null, success: successSpy}, - {status: 500, body: 'error'}); - })); -}); diff --git a/test/service/xhr.errorSpec.js b/test/service/xhr.errorSpec.js deleted file mode 100644 index f9ce2b72e729..000000000000 --- a/test/service/xhr.errorSpec.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -describe('$xhr.error', function() { - var log; - - beforeEach(inject(function($provide) { - $provide.value('$xhr.error', jasmine.createSpy('$xhr.error')); - $provide.factory('$xhrError', ['$xhr.error', identity]); - log = ''; - })); - - - function callback(code, response) { - expect(code).toEqual(200); - log = log + toJson(response) + ';'; - } - - - it('should handle non 200 status codes by forwarding to error handler', inject(function($browser, $xhr, $xhrError) { - $browser.xhr.expectPOST('/req', 'MyData').respond(500, 'MyError'); - $xhr('POST', '/req', 'MyData', callback); - $browser.xhr.flush(); - var cb = $xhrError.mostRecentCall.args[0].success; - expect(typeof cb).toEqual('function'); - expect($xhrError).toHaveBeenCalledWith( - {url: '/req', method: 'POST', data: 'MyData', success: cb}, - {status: 500, body: 'MyError'}); - })); -}); diff --git a/test/service/xhrSpec.js b/test/service/xhrSpec.js deleted file mode 100644 index 83c5f93fd24f..000000000000 --- a/test/service/xhrSpec.js +++ /dev/null @@ -1,271 +0,0 @@ -'use strict'; - -describe('$xhr', function() { - - var log; - - beforeEach(inject(function($provide) { - log = ''; - $provide.value('$xhr.error', jasmine.createSpy('xhr.error')); - $provide.factory('$xhrError', ['$xhr.error', identity]); - })); - - - function callback(code, response) { - log = log + '{code=' + code + '; response=' + toJson(response) + '}'; - } - - - it('should forward the request to $browser and decode JSON', inject(function($browser, $xhr) { - $browser.xhr.expectGET('/reqGET').respond('first'); - $browser.xhr.expectGET('/reqGETjson').respond('["second"]'); - $browser.xhr.expectPOST('/reqPOST', {post:'data'}).respond('third'); - - $xhr('GET', '/reqGET', null, callback); - $xhr('GET', '/reqGETjson', null, callback); - $xhr('POST', '/reqPOST', {post:'data'}, callback); - - $browser.xhr.flush(); - - expect(log).toEqual( - '{code=200; response="third"}' + - '{code=200; response=["second"]}' + - '{code=200; response="first"}'); - })); - - it('should allow all 2xx requests', inject(function($browser, $xhr) { - $browser.xhr.expectGET('/req1').respond(200, '1'); - $xhr('GET', '/req1', null, callback); - $browser.xhr.flush(); - - $browser.xhr.expectGET('/req2').respond(299, '2'); - $xhr('GET', '/req2', null, callback); - $browser.xhr.flush(); - - expect(log).toEqual( - '{code=200; response="1"}' + - '{code=299; response="2"}'); - })); - - - it('should handle exceptions in callback', inject(function($browser, $xhr, $log) { - $browser.xhr.expectGET('/reqGET').respond('first'); - $xhr('GET', '/reqGET', null, function() { throw "MyException"; }); - $browser.xhr.flush(); - - expect($log.error.logs.shift()).toContain('MyException'); - })); - - - it('should automatically deserialize json objects', inject(function($browser, $xhr) { - var response; - - $browser.xhr.expectGET('/foo').respond('{"foo":"bar","baz":23}'); - $xhr('GET', '/foo', function(code, resp) { - response = resp; - }); - $browser.xhr.flush(); - - expect(response).toEqual({foo:'bar', baz:23}); - })); - - - it('should automatically deserialize json arrays', inject(function($browser, $xhr) { - var response; - - $browser.xhr.expectGET('/foo').respond('[1, "abc", {"foo":"bar"}]'); - $xhr('GET', '/foo', function(code, resp) { - response = resp; - }); - $browser.xhr.flush(); - - expect(response).toEqual([1, 'abc', {foo:'bar'}]); - })); - - - it('should automatically deserialize json with security prefix', inject(function($browser, $xhr) { - var response; - - $browser.xhr.expectGET('/foo').respond(')]}\',\n[1, "abc", {"foo":"bar"}]'); - $xhr('GET', '/foo', function(code, resp) { - response = resp; - }); - $browser.xhr.flush(); - - expect(response).toEqual([1, 'abc', {foo:'bar'}]); - })); - - it('should call $xhr.error on error if no error callback provided', inject(function($browser, $xhr, $xhrError) { - var successSpy = jasmine.createSpy('success'); - - $browser.xhr.expectGET('/url').respond(500, 'error'); - $xhr('GET', '/url', null, successSpy); - $browser.xhr.flush(); - - expect(successSpy).not.toHaveBeenCalled(); - expect($xhrError).toHaveBeenCalledWith( - {method: 'GET', url: '/url', data: null, success: successSpy}, - {status: 500, body: 'error'} - ); - })); - - it('should call the error callback on error if provided', inject(function($browser, $xhr) { - var errorSpy = jasmine.createSpy('error'), - successSpy = jasmine.createSpy('success'); - - $browser.xhr.expectGET('/url').respond(500, 'error'); - $xhr('GET', '/url', null, successSpy, errorSpy); - $browser.xhr.flush(); - - expect(errorSpy).toHaveBeenCalledWith(500, 'error'); - expect(successSpy).not.toHaveBeenCalled(); - - errorSpy.reset(); - $xhr('GET', '/url', successSpy, errorSpy); - $browser.xhr.flush(); - - expect(errorSpy).toHaveBeenCalledWith(500, 'error'); - expect(successSpy).not.toHaveBeenCalled(); - })); - - describe('http headers', function() { - - describe('default headers', function() { - - it('should set default headers for GET request', inject(function($browser, $xhr) { - var callback = jasmine.createSpy('callback'); - - $browser.xhr.expectGET('URL', '', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest'}). - respond(234, 'OK'); - - $xhr('GET', 'URL', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - })); - - - it('should set default headers for POST request', inject(function($browser, $xhr) { - var callback = jasmine.createSpy('callback'); - - $browser.xhr.expectPOST('URL', 'xx', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded'}). - respond(200, 'OK'); - - $xhr('POST', 'URL', 'xx', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - })); - - - it('should set default headers for custom HTTP method', inject(function($browser, $xhr) { - var callback = jasmine.createSpy('callback'); - - $browser.xhr.expect('FOO', 'URL', '', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest'}). - respond(200, 'OK'); - - $xhr('FOO', 'URL', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - })); - - - describe('custom headers', function() { - - it('should allow appending a new header to the common defaults', inject(function($browser, $xhr) { - var callback = jasmine.createSpy('callback'); - - $browser.xhr.expectGET('URL', '', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest', - 'Custom-Header': 'value'}). - respond(200, 'OK'); - - $xhr.defaults.headers.common['Custom-Header'] = 'value'; - $xhr('GET', 'URL', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - callback.reset(); - - $browser.xhr.expectPOST('URL', 'xx', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Custom-Header': 'value'}). - respond(200, 'OK'); - - $xhr('POST', 'URL', 'xx', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - })); - - - it('should allow appending a new header to a method specific defaults', inject(function($browser, $xhr) { - var callback = jasmine.createSpy('callback'); - - $browser.xhr.expectGET('URL', '', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/json'}). - respond(200, 'OK'); - - $xhr.defaults.headers.get['Content-Type'] = 'application/json'; - $xhr('GET', 'URL', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - callback.reset(); - - $browser.xhr.expectPOST('URL', 'x', {'Accept': 'application/json, text/plain, */*', - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded'}). - respond(200, 'OK'); - - $xhr('POST', 'URL', 'x', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - })); - - - it('should support overwriting and deleting default headers', inject(function($browser, $xhr) { - var callback = jasmine.createSpy('callback'); - - $browser.xhr.expectGET('URL', '', {'Accept': 'application/json, text/plain, */*'}). - respond(200, 'OK'); - - //delete a default header - delete $xhr.defaults.headers.common['X-Requested-With']; - $xhr('GET', 'URL', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - callback.reset(); - - $browser.xhr.expectPOST('URL', 'xx', {'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json'}). - respond(200, 'OK'); - - //overwrite a default header - $xhr.defaults.headers.post['Content-Type'] = 'application/json'; - $xhr('POST', 'URL', 'xx', callback); - $browser.xhr.flush(); - expect(callback).toHaveBeenCalled(); - })); - }); - }); - }); - - describe('xsrf', function() { - it('should copy the XSRF cookie into a XSRF Header', inject(function($browser, $xhr) { - var code, response; - $browser.xhr - .expectPOST('URL', 'DATA', {'X-XSRF-TOKEN': 'secret'}) - .respond(234, 'OK'); - $browser.cookies('XSRF-TOKEN', 'secret'); - $xhr('POST', 'URL', 'DATA', function(c, r){ - code = c; - response = r; - }); - $browser.xhr.flush(); - expect(code).toEqual(234); - expect(response).toEqual('OK'); - })); - }); -}); diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index 82aa4956c519..2ddb26e168f2 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -1,10 +1,6 @@ 'use strict'; describe("widget", function() { - beforeEach(inject(function($provide){ - $provide.factory('$xhrCache', ['$xhr.cache', identity]); - })); - describe('ng:switch', inject(function($rootScope, $compile) { it('should switch on value change', inject(function($rootScope, $compile) { var element = $compile( @@ -60,26 +56,26 @@ describe("widget", function() { describe('ng:include', inject(function($rootScope, $compile) { - it('should include on external file', inject(function($rootScope, $compile, $xhrCache) { + it('should include on external file', inject(function($rootScope, $compile, $cacheFactory) { var element = jqLite(''); var element = $compile(element)($rootScope); $rootScope.childScope = $rootScope.$new(); $rootScope.childScope.name = 'misko'; $rootScope.url = 'myUrl'; - $xhrCache.data.myUrl = {value:'{{name}}'}; + $cacheFactory.get('templates').put('myUrl', '{{name}}'); $rootScope.$digest(); expect(element.text()).toEqual('misko'); })); it('should remove previously included text if a falsy value is bound to src', - inject(function($rootScope, $compile, $xhrCache) { + inject(function($rootScope, $compile, $cacheFactory) { var element = jqLite(''); var element = $compile(element)($rootScope); $rootScope.childScope = $rootScope.$new(); $rootScope.childScope.name = 'igor'; $rootScope.url = 'myUrl'; - $xhrCache.data.myUrl = {value:'{{name}}'}; + $cacheFactory.get('templates').put('myUrl', '{{name}}'); $rootScope.$digest(); expect(element.text()).toEqual('igor'); @@ -91,11 +87,11 @@ describe("widget", function() { })); - it('should allow this for scope', inject(function($rootScope, $compile, $xhrCache) { + it('should allow this for scope', inject(function($rootScope, $compile, $cacheFactory) { var element = jqLite(''); var element = $compile(element)($rootScope); $rootScope.url = 'myUrl'; - $xhrCache.data.myUrl = {value:'{{"abc"}}'}; + $cacheFactory.get('templates').put('myUrl', '{{"abc"}}'); $rootScope.$digest(); // TODO(misko): because we are using scope==this, the eval gets registered // during the flush phase and hence does not get called. @@ -108,28 +104,28 @@ describe("widget", function() { it('should evaluate onload expression when a partial is loaded', - inject(function($rootScope, $compile, $xhrCache) { + inject(function($rootScope, $compile, $cacheFactory) { var element = jqLite(''); var element = $compile(element)($rootScope); expect($rootScope.loaded).not.toBeDefined(); $rootScope.url = 'myUrl'; - $xhrCache.data.myUrl = {value:'my partial'}; + $cacheFactory.get('templates').put('myUrl', 'my partial'); $rootScope.$digest(); expect(element.text()).toEqual('my partial'); expect($rootScope.loaded).toBe(true); })); - it('should destroy old scope', inject(function($rootScope, $compile, $xhrCache) { + it('should destroy old scope', inject(function($rootScope, $compile, $cacheFactory) { var element = jqLite(''); var element = $compile(element)($rootScope); expect($rootScope.$$childHead).toBeFalsy(); $rootScope.url = 'myUrl'; - $xhrCache.data.myUrl = {value:'my partial'}; + $cacheFactory.get('templates').put('myUrl', 'my partial'); $rootScope.$digest(); expect($rootScope.$$childHead).toBeTruthy(); @@ -137,6 +133,55 @@ describe("widget", function() { $rootScope.$digest(); expect($rootScope.$$childHead).toBeFalsy(); })); + + it('should do xhr request and cache it', inject(function($rootScope, $browser, $compile) { + var element = $compile('')($rootScope); + var $browserXhr = $browser.xhr; + $browserXhr.expectGET('myUrl').respond('my partial'); + + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + $browserXhr.flush(); + expect(element.text()).toEqual('my partial'); + + $rootScope.url = null; + $rootScope.$digest(); + expect(element.text()).toEqual(''); + + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + expect(element.text()).toEqual('my partial'); + dealoc($rootScope); + })); + + it('should clear content when error during xhr request', + inject(function($browser, $compile, $rootScope) { + var element = $compile('content')($rootScope); + var $browserXhr = $browser.xhr; + $browserXhr.expectGET('myUrl').respond(404, ''); + + $rootScope.url = 'myUrl'; + $rootScope.$digest(); + $browserXhr.flush(); + + expect(element.text()).toBe(''); + })); + + it('should be async even if served from cache', inject(function($rootScope, $compile, $cacheFactory) { + var element = $compile('')($rootScope); + + $rootScope.url = 'myUrl'; + $cacheFactory.get('templates').put('myUrl', 'my partial'); + + var called = 0; + // we want to assert only during first watch + $rootScope.$watch(function() { + if (!called++) expect(element.text()).toBe(''); + }); + + $rootScope.$digest(); + expect(element.text()).toBe('my partial'); + })); })); @@ -587,6 +632,36 @@ describe("widget", function() { expect($rootScope.$element.text()).toEqual('2'); })); + + it('should clear the content when error during xhr request', + inject(function($route, $location, $rootScope, $browser) { + $route.when('/foo', {controller: noop, template: 'myUrl1'}); + + $location.path('/foo'); + $browser.xhr.expectGET('myUrl1').respond(404, ''); + $rootScope.$element.text('content'); + + $rootScope.$digest(); + $browser.xhr.flush(); + + expect($rootScope.$element.text()).toBe(''); + })); + + it('should be async even if served from cache', + inject(function($route, $rootScope, $location, $cacheFactory) { + $route.when('/foo', {controller: noop, template: 'myUrl1'}); + $cacheFactory.get('templates').put('myUrl1', 'my partial'); + $location.path('/foo'); + + var called = 0; + // we want to assert only during first watch + $rootScope.$watch(function() { + if (!called++) expect(element.text()).toBe(''); + }); + + $rootScope.$digest(); + expect(element.text()).toBe('my partial'); + })); }); From 6993a409795ee0afb320a33dd9f68ef68e0aa174 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 16 Aug 2011 21:24:53 +0200 Subject: [PATCH 08/28] feat(mocks.$httpBackend): add $httpBackend mock $httpBackend mock allows: - expecting (asserting) requests - stubbing (responding without asserting) Add empty $httpBackend service (currently just wrapper for $browser.xhr) --- angularFiles.js | 1 + src/AngularPublic.js | 1 + src/angular-mocks.js | 167 ++++++++- src/service/http.js | 13 +- src/service/httpBackend.js | 6 + test/angular-mocksSpec.js | 397 ++++++++++++++++++++ test/service/httpSpec.js | 735 +++++++++++++++++-------------------- test/widgetsSpec.js | 68 ++-- 8 files changed, 944 insertions(+), 444 deletions(-) create mode 100644 src/service/httpBackend.js diff --git a/angularFiles.js b/angularFiles.js index e0191572b83a..3107188ba986 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -32,6 +32,7 @@ angularFiles = { 'src/service/sniffer.js', 'src/service/window.js', 'src/service/http.js', + 'src/service/httpBackend.js', 'src/service/locale.js', 'src/directives.js', 'src/markups.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 6ec67fd40683..05d6417f7dc6 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -77,6 +77,7 @@ function ngModule($provide, $injector) { $provide.service('$filter', $FilterProvider); $provide.service('$formFactory', $FormFactoryProvider); $provide.service('$http', $HttpProvider); + $provide.service('$httpBackend', $HttpBackendProvider); $provide.service('$location', $LocationProvider); $provide.service('$log', $LogProvider); $provide.service('$parse', $ParseProvider); diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 73bc4cbd93c7..bc0578f5c581 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -21,6 +21,7 @@ angular.module.ngMock = function($provide){ $provide.service('$browser', angular.module.ngMock.$BrowserProvider); $provide.service('$exceptionHandler', angular.module.ngMock.$ExceptionHandlerProvider); $provide.service('$log', angular.module.ngMock.$LogProvider); + $provide.service('$httpBackend', angular.module.ngMock.$HttpBackendProvider); }; angular.module.ngMock.$inject = ['$provide']; @@ -38,8 +39,6 @@ angular.module.ngMock.$inject = ['$provide']; * * The following apis can be used in tests: * - * - {@link #xhr} — enables testing of code that uses - * the {@link angular.module.ng.$xhr $xhr service} to make XmlHttpRequests. * - $browser.defer — enables testing of code that uses * {@link angular.module.ng.$defer $defer} for executing functions via the `setTimeout` api. */ @@ -720,6 +719,170 @@ angular.module.ngMock.dump = function(object){ } }; +/** + * @ngdoc object + * @name angular.module.ngMock.$httpBackend + */ +angular.module.ngMock.$HttpBackendProvider = function() { + this.$get = function() { + var definitions = [], + expectations = [], + responses = []; + + function createResponse(status, data, headers) { + return angular.isNumber(status) ? [status, data, headers] : [200, status, data]; + } + + // TODO(vojta): change params to: method, url, data, headers, callback + function $httpBackend(method, url, data, callback, headers) { + var xhr = new MockXhr(), + expectation = expectations[0], + wasExpected = false; + + if (expectation && expectation.match(method, url)) { + if (!expectation.matchData(data)) + throw Error('Expected ' + method + ' ' + url + ' with different data'); + + if (!expectation.matchHeaders(headers)) + throw Error('Expected ' + method + ' ' + url + ' with different headers'); + + expectations.shift(); + + if (expectation.response) { + responses.push(function() { + xhr.$$headers = expectation.response[2]; + callback(expectation.response[0], expectation.response[1]); + }); + return method == 'JSONP' ? undefined : xhr; + } + wasExpected = true; + } + + var i = -1, definition; + while ((definition = definitions[++i])) { + if (definition.match(method, url, data, headers || {})) { + if (!definition.response) throw Error('No response defined !'); + responses.push(function() { + var response = angular.isFunction(definition.response) ? + definition.response(method, url, data, headers) : definition.response; + xhr.$$headers = response[2]; + callback(response[0], response[1]); + }); + return method == 'JSONP' ? undefined : xhr; + } + } + throw wasExpected ? Error('No response defined !') : + Error('Unexpected request: ' + method + ' ' + url); + } + + $httpBackend.when = function(method, url, data, headers) { + var definition = new MockHttpExpectation(method, url, data, headers); + definitions.push(definition); + return { + then: function(status, data, headers) { + definition.response = angular.isFunction(status) ? status : createResponse(status, data, headers); + } + }; + }; + + $httpBackend.expect = function(method, url, data, headers) { + var expectation = new MockHttpExpectation(method, url, data, headers); + expectations.push(expectation); + return { + respond: function(status, data, headers) { + expectation.response = createResponse(status, data, headers); + } + }; + }; + + $httpBackend.flush = function(count) { + count = count || responses.length; + while (count--) { + if (!responses.length) throw Error('No more pending requests'); + responses.shift()(); + } + }; + + + + $httpBackend.verifyExpectations = function() { + if (expectations.length) { + throw Error('Unsatisfied requests: ' + expectations.join(', ')); + } + }; + + $httpBackend.resetExpectations = function() { + expectations = []; + responses = []; + }; + + return $httpBackend; + }; +}; + +function MockHttpExpectation(method, url, data, headers) { + + this.match = function(m, u, d, h) { + if (method != m) return false; + if (!this.matchUrl(u)) return false; + if (angular.isDefined(d) && !this.matchData(d)) return false; + if (angular.isDefined(h) && !this.matchHeaders(h)) return false; + return true; + }; + + this.matchUrl = function(u) { + if (!url) return true; + if (angular.isFunction(url.test)) { + if (!url.test(u)) return false; + } else if (url != u) return false; + + return true; + }; + + this.matchHeaders = function(h) { + if (angular.isUndefined(headers)) return true; + if (angular.isFunction(headers)) { + if (!headers(h)) return false; + } else if (!angular.equals(headers, h)) return false; + + return true; + }; + + this.matchData = function(d) { + if (angular.isUndefined(data)) return true; + if (data && angular.isFunction(data.test)) { + if (!data.test(d)) return false; + } else if (data != d) return false; + + return true; + }; + + this.toString = function() { + return method + ' ' + url; + }; +} + +function MockXhr() { + + // hack for testing $http + MockXhr.$$lastInstance = this; + + this.getResponseHeader = function(name) { + return this.$$headers[name]; + }; + + this.getAllResponseHeaders = function() { + var lines = []; + + angular.forEach(this.$$headers, function(value, key) { + lines.push(key + ': ' + value); + }); + return lines.join('\n'); + }; + + this.abort = noop; +} + window.jstestdriver && (function(window){ /** * Global method to output any number of objects into JSTD console. Useful for debugging. diff --git a/src/service/http.js b/src/service/http.js index 13621f903499..087c3809703b 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -51,6 +51,7 @@ function transform(data, fns, param) { /** * @ngdoc object * @name angular.module.ng.$http + * @requires $httpBacked * @requires $browser * @requires $exceptionHandler * @requires $cacheFactory @@ -85,8 +86,8 @@ function $HttpProvider() { } }; - this.$get = ['$browser', '$exceptionHandler', '$cacheFactory', '$rootScope', - function($browser, $exceptionHandler, $cacheFactory, $rootScope) { + this.$get = ['$httpBackend', '$browser', '$exceptionHandler', '$cacheFactory', '$rootScope', + function($httpBackend, $browser, $exceptionHandler, $cacheFactory, $rootScope) { var cache = $cacheFactory('$http'), pendingRequestsCount = 0; @@ -235,7 +236,7 @@ function $HttpProvider() { /** * Represents Request object, returned by $http() * - * !!! ACCESS CLOSURE VARS: $browser, $config, $log, $rootScope, cache, pendingRequestsCount + * !!! ACCESS CLOSURE VARS: $httpBackend, $browser, $config, $log, $rootScope, cache, pendingRequestsCount */ function XhrFuture() { var rawRequest, cfg = {}, callbacks = [], @@ -243,7 +244,7 @@ function $HttpProvider() { parsedHeaders; /** - * Callback registered to $browser.xhr: + * Callback registered to $httpBackend(): * - caches the response if desired * - calls fireCallbacks() * - clears the reference to raw request object @@ -265,7 +266,7 @@ function $HttpProvider() { * Fire all registered callbacks for given status code * * This method when: - * - serving response from real request ($browser.xhr callback) + * - serving response from real request * - serving response from cache * * It does: @@ -368,7 +369,7 @@ function $HttpProvider() { fireCallbacks(fromCache[1], fromCache[0]); }); } else { - rawRequest = $browser.xhr(cfg.method, cfg.url, data, done, headers, cfg.timeout); + rawRequest = $httpBackend(cfg.method, cfg.url, data, done, headers, cfg.timeout); } pendingRequestsCount++; diff --git a/src/service/httpBackend.js b/src/service/httpBackend.js new file mode 100644 index 000000000000..af3de9701e51 --- /dev/null +++ b/src/service/httpBackend.js @@ -0,0 +1,6 @@ +function $HttpBackendProvider() { + this.$get = ['$browser', function($browser) { + return $browser.xhr; + }]; +} + diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index acb019c7b764..4551d11d655a 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -342,4 +342,401 @@ describe('mocks', function() { expect(count).toBe(2); }); }); + + + describe('$httpBackend', function() { + var hb, callback; + + beforeEach(inject(function($httpBackend) { + callback = jasmine.createSpy('callback'); + hb = $httpBackend; + })); + + + it('should respond with first matched definition', function() { + hb.when('GET', '/url1').then(200, 'content', {}); + hb.when('GET', '/url1').then(201, 'another', {}); + + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('content'); + }); + + hb('GET', '/url1', null, callback); + expect(callback).not.toHaveBeenCalled(); + hb.flush(); + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should throw error when unexpected request', function() { + hb.when('GET', '/url1').then(200, 'content'); + expect(function() { + hb('GET', '/xxx'); + }).toThrow('Unexpected request: GET /xxx'); + }); + + + it('should match headers if specified', function() { + hb.when('GET', '/url', null, {'X': 'val1'}).then(201, 'content1'); + hb.when('GET', '/url', null, {'X': 'val2'}).then(202, 'content2'); + hb.when('GET', '/url').then(203, 'content3'); + + hb('GET', '/url', null, function(status, response) { + expect(status).toBe(203); + expect(response).toBe('content3'); + }); + + hb('GET', '/url', null, function(status, response) { + expect(status).toBe(201); + expect(response).toBe('content1'); + }, {'X': 'val1'}); + + hb('GET', '/url', null, function(status, response) { + expect(status).toBe(202); + expect(response).toBe('content2'); + }, {'X': 'val2'}); + + hb.flush(); + }); + + + it('should match data if specified', function() { + hb.when('GET', '/a/b', '{a: true}').then(201, 'content1'); + hb.when('GET', '/a/b').then(202, 'content2'); + + hb('GET', '/a/b', '{a: true}', function(status, response) { + expect(status).toBe(201); + expect(response).toBe('content1'); + }); + + hb('GET', '/a/b', null, function(status, response) { + expect(status).toBe(202); + expect(response).toBe('content2'); + }); + + hb.flush(); + }); + + + it('should match only method', function() { + hb.when('GET').then(202, 'c'); + callback.andCallFake(function(status, response) { + expect(status).toBe(202); + expect(response).toBe('c'); + }); + + hb('GET', '/some', null, callback, {}); + hb('GET', '/another', null, callback, {'X-Fake': 'Header'}); + hb('GET', '/third', 'some-data', callback, {}); + hb.flush(); + + expect(callback).toHaveBeenCalled(); + }); + + + it('should expose given headers', function() { + hb.when('GET', '/u1').then(200, null, {'X-Fake': 'Header', 'Content-Type': 'application/json'}); + var xhr = hb('GET', '/u1', null, noop, {}); + hb.flush(); + expect(xhr.getResponseHeader('X-Fake')).toBe('Header'); + expect(xhr.getAllResponseHeaders()).toBe('X-Fake: Header\nContent-Type: application/json'); + }); + + + it('should preserve the order of requests', function() { + hb.when('GET', '/url1').then(200, 'first'); + hb.when('GET', '/url2').then(201, 'second'); + + hb('GET', '/url2', null, callback); + hb('GET', '/url1', null, callback); + + hb.flush(); + + expect(callback.callCount).toBe(2); + expect(callback.argsForCall[0]).toEqual([201, 'second']); + expect(callback.argsForCall[1]).toEqual([200, 'first']); + }); + + + it('then() should take function', function() { + hb.when('GET', '/some').then(function(m, u, d, h) { + return [301, m + u + ';' + d + ';a=' + h.a, {'Connection': 'keep-alive'}]; + }); + + var xhr = hb('GET', '/some', 'data', callback, {a: 'b'}); + hb.flush(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe(301); + expect(callback.mostRecentCall.args[1]).toBe('GET/some;data;a=b'); + expect(xhr.getResponseHeader('Connection')).toBe('keep-alive'); + }); + + + it('expect() should require specified order', function() { + hb.expect('GET', '/url1').respond(200, ''); + hb.expect('GET', '/url2').respond(200, ''); + + expect(function() { + hb('GET', '/url2', null, noop, {}); + }).toThrow('Unexpected request: GET /url2'); + }); + + + it('expect() should have precendence over when()', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(300); + expect(response).toBe('expect'); + }); + + hb.when('GET', '/url').then(200, 'when'); + hb.expect('GET', '/url').respond(300, 'expect'); + + hb('GET', '/url', null, callback, {}); + hb.flush(); + expect(callback).toHaveBeenCalledOnce(); + }); + + + it ('should throw exception when only headers differes from expectation', function() { + hb.when('GET').then(200, '', {}); + hb.expect('GET', '/match', undefined, {'Content-Type': 'application/json'}); + + expect(function() { + hb('GET', '/match', null, noop, {}); + }).toThrow('Expected GET /match with different headers'); + }); + + + it ('should throw exception when only data differes from expectation', function() { + hb.when('GET').then(200, '', {}); + hb.expect('GET', '/match', 'some-data'); + + expect(function() { + hb('GET', '/match', 'different', noop, {}); + }).toThrow('Expected GET /match with different data'); + }); + + + it('expect() should without respond() and use then()', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(201); + expect(response).toBe('data'); + }); + + hb.when('GET', '/some').then(201, 'data'); + hb.expect('GET', '/some'); + hb('GET', '/some', null, callback); + hb.flush(); + + expect(callback).toHaveBeenCalled(); + expect(function() { hb.verifyExpectations(); }).not.toThrow(); + }); + + + it('flush() should not flush requests fired during callbacks', function() { + // regression + hb.when('GET').then(200, ''); + hb('GET', '/some', null, function() { + hb('GET', '/other', null, callback); + }); + + hb.flush(); + expect(callback).not.toHaveBeenCalled(); + }); + + + it('flush() should flush given number of pending requests', function() { + hb.when('GET').then(200, ''); + hb('GET', '/some', null, callback); + hb('GET', '/some', null, callback); + hb('GET', '/some', null, callback); + + hb.flush(2); + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(2); + }); + + + it('flush() should throw exception when flushing more requests than pending', function() { + hb.when('GET').then(200, ''); + hb('GET', '/url', null, callback); + + expect(function() {hb.flush(2);}).toThrow('No more pending requests'); + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('respond() should set default status 200 if not defined', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('some-data'); + }); + + hb.expect('GET', '/url1').respond('some-data'); + hb.expect('GET', '/url2').respond('some-data', {'X-Header': 'true'}); + hb('GET', '/url1', null, callback); + hb('GET', '/url2', null, callback); + hb.flush(); + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(2); + }); + + + it('then() should set default status 200 if not defined', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('some-data'); + }); + + hb.when('GET', '/url1').then('some-data'); + hb.when('GET', '/url2').then('some-data', {'X-Header': 'true'}); + hb('GET', '/url1', null, callback); + hb('GET', '/url2', null, callback); + hb.flush(); + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(2); + }); + + + it('should respond with definition if no response for expectation', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(201); + expect(response).toBe('def-response'); + }); + + hb.when('GET').then(201, 'def-response'); + hb.expect('GET', '/some-url'); + + hb('GET', '/some-url', null, callback); + hb.flush(); + expect(callback).toHaveBeenCalledOnce(); + hb.verifyExpectations(); + }); + + + it('should throw an exception if no response defined', function() { + hb.when('GET', '/test'); + expect(function() { + hb('GET', '/test', null, callback); + }).toThrow('No response defined !'); + }); + + + it('should throw an exception if no response for expection and no definition', function() { + hb.expect('GET', '/url'); + expect(function() { + hb('GET', '/url', null, callback); + }).toThrow('No response defined !'); + }); + + + it('should respond undefined when JSONP method', function() { + hb.when('JSONP', '/url1').then(200); + hb.expect('JSONP', '/url2').respond(200); + + expect(hb('JSONP', '/url1')).toBeUndefined(); + expect(hb('JSONP', '/url2')).toBeUndefined(); + }); + + + describe('verify', function() { + + it('should throw exception if not all expectations were satisfied', function() { + hb.expect('POST', '/u1', 'ddd').respond(201, '', {}); + hb.expect('GET', '/u2').respond(200, '', {}); + hb.expect('POST', '/u3').respond(201, '', {}); + + hb('POST', '/u1', 'ddd', noop, {}); + + expect(function() {hb.verifyExpectations();}) + .toThrow('Unsatisfied requests: GET /u2, POST /u3'); + }); + + + it('should do nothing when no expectation', function() { + hb.when('DELETE', '/some').then(200, ''); + + expect(function() {hb.verifyExpectations();}).not.toThrow(); + }); + + + it('should do nothing when all expectations satisfied', function() { + hb.expect('GET', '/u2').respond(200, '', {}); + hb.expect('POST', '/u3').respond(201, '', {}); + hb.when('DELETE', '/some').then(200, ''); + + hb('GET', '/u2', noop); + hb('POST', '/u3', noop); + + expect(function() {hb.verifyExpectations();}).not.toThrow(); + }); + }); + + + describe('reset', function() { + + it('should remove all expectations', function() { + hb.expect('GET', '/u2').respond(200, '', {}); + hb.expect('POST', '/u3').respond(201, '', {}); + hb.resetExpectations(); + + expect(function() {hb.verifyExpectations();}).not.toThrow(); + }); + + + it('should remove all responses', function() { + hb.expect('GET', '/url').respond(200, '', {}); + hb('GET', '/url', null, callback, {}); + hb.resetExpectations(); + hb.flush(); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + + describe('MockHttpExpectation', function() { + + it('should accept url as regexp', function() { + var exp = new MockHttpExpectation('GET', /^\/x/); + + expect(exp.match('GET', '/x')).toBe(true); + expect(exp.match('GET', '/xxx/x')).toBe(true); + expect(exp.match('GET', 'x')).toBe(false); + expect(exp.match('GET', 'a/x')).toBe(false); + }); + + + it('should accept data as regexp', function() { + var exp = new MockHttpExpectation('POST', '/url', /\{.*?\}/); + + expect(exp.match('POST', '/url', '{"a": "aa"}')).toBe(true); + expect(exp.match('POST', '/url', '{"one": "two"}')).toBe(true); + expect(exp.match('POST', '/url', '{"one"')).toBe(false); + }); + + + it('should ignore data only if undefined (not null or false)', function() { + var exp = new MockHttpExpectation('POST', '/url', null); + expect(exp.matchData(null)).toBe(true); + expect(exp.matchData('some-data')).toBe(false); + + exp = new MockHttpExpectation('POST', '/url', undefined); + expect(exp.matchData(null)).toBe(true); + expect(exp.matchData('some-data')).toBe(true); + }); + + + it('should accept headers as function', function() { + var exp = new MockHttpExpectation('GET', '/url', undefined, function(h) { + return h['Content-Type'] == 'application/json'; + }); + + expect(exp.matchHeaders({})).toBe(false); + expect(exp.matchHeaders({'Content-Type': 'application/json', 'X-Another': 'true'})).toBe(true); + }); + }); + }); }); diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 196a57edacc4..75e85359d046 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -3,98 +3,68 @@ // TODO(vojta): refactor these tests to use new inject() syntax describe('$http', function() { - var $http, $browser, $exceptionHandler, // services - method, url, data, headers, timeout, // passed arguments - onSuccess, onError, // callback spies - scope, errorLogs, respond, rawXhrObject, future; + var $http, $browser, $exceptionHandler, $httpBackend, + scope, callback, future, callback; beforeEach(inject(function($injector) { $injector.get('$exceptionHandlerProvider').mode('log'); scope = $injector.get('$rootScope'); $http = $injector.get('$http'); $browser = $injector.get('$browser'); + $httpBackend = $injector.get('$httpBackend'); $exceptionHandler = $injector.get('$exceptionHandler'); - - // TODO(vojta): move this into mock browser ? - respond = method = url = data = headers = null; - rawXhrObject = { - abort: jasmine.createSpy('request.abort'), - getResponseHeader: function(h) {return h + '-val';}, - getAllResponseHeaders: function() { - return 'content-encoding: gzip\nserver: Apache\n'; - } - }; - spyOn(scope, '$apply'); - spyOn($browser, 'xhr').andCallFake(function(m, u, d, c, h, t) { - method = m; - url = u; - data = d; - respond = c; - headers = h; - timeout = t; - return rawXhrObject; - }); + callback = jasmine.createSpy('callback'); })); afterEach(function() { - // expect($exceptionHandler.errors.length).toBe(0); + if ($exceptionHandler.errors.length) throw $exceptionHandler.errors; + $httpBackend.verifyExpectations(); }); - function doCommonXhr(method, url) { - future = $http({method: method || 'GET', url: url || '/url'}); - - onSuccess = jasmine.createSpy('on200'); - onError = jasmine.createSpy('on400'); - future.on('200', onSuccess); - future.on('400', onError); - - return future; - } - it('should do basic request', function() { + $httpBackend.expect('GET', '/url').respond(''); $http({url: '/url', method: 'GET'}); - expect($browser.xhr).toHaveBeenCalledOnce(); - expect(url).toBe('/url'); - expect(method).toBe('GET'); }); it('should pass data if specified', function() { + $httpBackend.expect('POST', '/url', 'some-data').respond(''); $http({url: '/url', method: 'POST', data: 'some-data'}); - expect($browser.xhr).toHaveBeenCalledOnce(); - expect(data).toBe('some-data'); }); - it('should pass timeout if specified', function() { - $http({url: '/url', method: 'POST', timeout: 5000}); - expect($browser.xhr).toHaveBeenCalledOnce(); - expect(timeout).toBe(5000); - }); + // TODO(vojta): test passing timeout describe('callbacks', function() { - beforeEach(doCommonXhr); + function throwing(name) { + return function() { + throw name; + }; + } it('should log exceptions', function() { - onSuccess.andThrow('exception in success callback'); - onError.andThrow('exception in error callback'); + $httpBackend.expect('GET', '/url1').respond(200, 'content'); + $httpBackend.expect('GET', '/url2').respond(400, ''); - respond(200, 'content'); - expect($exceptionHandler.errors.pop()).toContain('exception in success callback'); + $http({url: '/url1', method: 'GET'}).on('200', throwing('exception in success callback')); + $http({url: '/url2', method: 'GET'}).on('400', throwing('exception in error callback')); + $httpBackend.flush(); - respond(400, ''); - expect($exceptionHandler.errors.pop()).toContain('exception in error callback'); + expect($exceptionHandler.errors.shift()).toContain('exception in success callback'); + expect($exceptionHandler.errors.shift()).toContain('exception in error callback'); }); it('should log more exceptions', function() { - onError.andThrow('exception in error callback'); - future.on('500', onError).on('50x', onError); - respond(500, ''); + $httpBackend.expect('GET', '/url').respond(500, ''); + $http({url: '/url', method: 'GET'}) + .on('500', throwing('exception in error callback')) + .on('5xx', throwing('exception in error callback')); + $httpBackend.flush(); expect($exceptionHandler.errors.length).toBe(2); $exceptionHandler.errors = []; @@ -102,82 +72,76 @@ describe('$http', function() { it('should get response as first param', function() { - respond(200, 'response'); - expect(onSuccess).toHaveBeenCalledOnce(); - expect(onSuccess.mostRecentCall.args[0]).toBe('response'); + $httpBackend.expect('GET', '/url').respond('some-content'); + $http({url: '/url', method: 'GET'}).on('200', callback); + $httpBackend.flush(); - respond(400, 'empty'); - expect(onError).toHaveBeenCalledOnce(); - expect(onError.mostRecentCall.args[0]).toBe('empty'); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe('some-content'); }); it('should get status code as second param', function() { - respond(200, 'response'); - expect(onSuccess).toHaveBeenCalledOnce(); - expect(onSuccess.mostRecentCall.args[1]).toBe(200); + $httpBackend.expect('GET', '/url').respond(250, 'some-content'); + $http({url: '/url', method: 'GET'}).on('2xx', callback); + $httpBackend.flush(); - respond(400, 'empty'); - expect(onError).toHaveBeenCalledOnce(); - expect(onError.mostRecentCall.args[1]).toBe(400); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[1]).toBe(250); }); }); describe('response headers', function() { - var callback; - - beforeEach(function() { - callback = jasmine.createSpy('callback'); - }); - it('should return single header', function() { + $httpBackend.expect('GET', '/url').respond('', {'date': 'date-val'}); callback.andCallFake(function(r, s, header) { expect(header('date')).toBe('date-val'); }); $http({url: '/url', method: 'GET'}).on('200', callback); - respond(200, ''); + $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); }); it('should return null when single header does not exist', function() { + $httpBackend.expect('GET', '/url').respond('', {'Some-Header': 'Fake'}); callback.andCallFake(function(r, s, header) { header(); // we need that to get headers parsed first expect(header('nothing')).toBe(null); }); $http({url: '/url', method: 'GET'}).on('200', callback); - respond(200, ''); + $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); }); it('should return all headers as object', function() { + $httpBackend.expect('GET', '/url').respond('', {'content-encoding': 'gzip', 'server': 'Apache'}); callback.andCallFake(function(r, s, header) { expect(header()).toEqual({'content-encoding': 'gzip', 'server': 'Apache'}); }); $http({url: '/url', method: 'GET'}).on('200', callback); - respond(200, ''); + $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); }); it('should return empty object for jsonp request', function() { - // jsonp doesn't return raw object - rawXhrObject = undefined; callback.andCallFake(function(r, s, headers) { expect(headers()).toEqual({}); }); + $httpBackend.expect('JSONP', '/some').respond(200); $http({url: '/some', method: 'JSONP'}).on('200', callback); - respond(200, ''); + $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); }); }); @@ -250,202 +214,192 @@ describe('$http', function() { describe('request headers', function() { it('should send custom headers', function() { + $httpBackend.expect('GET', '/url', undefined, function(headers) { + return headers['Custom'] == 'header' && headers['Content-Type'] == 'application/json'; + }).respond(''); + $http({url: '/url', method: 'GET', headers: { 'Custom': 'header', 'Content-Type': 'application/json' }}); - expect(headers['Custom']).toEqual('header'); - expect(headers['Content-Type']).toEqual('application/json'); + $httpBackend.flush(); }); it('should set default headers for GET request', function() { - $http({url: '/url', method: 'GET', headers: {}}); + $httpBackend.expect('GET', '/url', undefined, function(headers) { + return headers['Accept'] == 'application/json, text/plain, */*' && + headers['X-Requested-With'] == 'XMLHttpRequest'; + }).respond(''); - expect(headers['Accept']).toBe('application/json, text/plain, */*'); - expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + $http({url: '/url', method: 'GET', headers: {}}); + $httpBackend.flush(); }); it('should set default headers for POST request', function() { - $http({url: '/url', method: 'POST', headers: {}}); + $httpBackend.expect('POST', '/url', undefined, function(headers) { + return headers['Accept'] == 'application/json, text/plain, */*' && + headers['X-Requested-With'] == 'XMLHttpRequest' && + headers['Content-Type'] == 'application/json'; + }).respond(''); - expect(headers['Accept']).toBe('application/json, text/plain, */*'); - expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); - expect(headers['Content-Type']).toBe('application/json'); + $http({url: '/url', method: 'POST', headers: {}}); + $httpBackend.flush(); }); it('should set default headers for PUT request', function() { - $http({url: '/url', method: 'PUT', headers: {}}); + $httpBackend.expect('PUT', '/url', undefined, function(headers) { + return headers['Accept'] == 'application/json, text/plain, */*' && + headers['X-Requested-With'] == 'XMLHttpRequest' && + headers['Content-Type'] == 'application/json'; + }).respond(''); - expect(headers['Accept']).toBe('application/json, text/plain, */*'); - expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); - expect(headers['Content-Type']).toBe('application/json'); + $http({url: '/url', method: 'PUT', headers: {}}); + $httpBackend.flush(); }); it('should set default headers for custom HTTP method', function() { - $http({url: '/url', method: 'FOO', headers: {}}); + $httpBackend.expect('FOO', '/url', undefined, function(headers) { + return headers['Accept'] == 'application/json, text/plain, */*' && + headers['X-Requested-With'] == 'XMLHttpRequest'; + }).respond(''); - expect(headers['Accept']).toBe('application/json, text/plain, */*'); - expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); + $http({url: '/url', method: 'FOO', headers: {}}); + $httpBackend.flush(); }); it('should override default headers with custom', function() { + $httpBackend.expect('POST', '/url', undefined, function(headers) { + return headers['Accept'] == 'Rewritten' && + headers['X-Requested-With'] == 'XMLHttpRequest' && + headers['Content-Type'] == 'Rewritten'; + }).respond(''); + $http({url: '/url', method: 'POST', headers: { 'Accept': 'Rewritten', 'Content-Type': 'Rewritten' }}); - - expect(headers['Accept']).toBe('Rewritten'); - expect(headers['X-Requested-With']).toBe('XMLHttpRequest'); - expect(headers['Content-Type']).toBe('Rewritten'); + $httpBackend.flush(); }); it('should set the XSRF cookie into a XSRF header', function() { + function checkXSRF(secret) { + return function(headers) { + return headers['X-XSRF-TOKEN'] == secret; + }; + } + $browser.cookies('XSRF-TOKEN', 'secret'); + $httpBackend.expect('GET', '/url', undefined, checkXSRF('secret')).respond(''); + $httpBackend.expect('POST', '/url', undefined, checkXSRF('secret')).respond(''); + $httpBackend.expect('PUT', '/url', undefined, checkXSRF('secret')).respond(''); + $httpBackend.expect('DELETE', '/url', undefined, checkXSRF('secret')).respond(''); $http({url: '/url', method: 'GET'}); - expect(headers['X-XSRF-TOKEN']).toBe('secret'); - $http({url: '/url', method: 'POST', headers: {'S-ome': 'Header'}}); - expect(headers['X-XSRF-TOKEN']).toBe('secret'); - $http({url: '/url', method: 'PUT', headers: {'Another': 'Header'}}); - expect(headers['X-XSRF-TOKEN']).toBe('secret'); - $http({url: '/url', method: 'DELETE', headers: {}}); - expect(headers['X-XSRF-TOKEN']).toBe('secret'); + + $httpBackend.flush(); }); }); describe('short methods', function() { - it('should have .get()', function() { - $http.get('/url'); + function checkHeader(name, value) { + return function(headers) { + return headers[name] == value; + }; + } - expect(method).toBe('GET'); - expect(url).toBe('/url'); + it('should have get()', function() { + $httpBackend.expect('GET', '/url').respond(''); + $http.get('/url'); }); - it('.get() should allow config param', function() { + it('get() should allow config param', function() { + $httpBackend.expect('GET', '/url', undefined, checkHeader('Custom', 'Header')).respond(''); $http.get('/url', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('GET'); - expect(url).toBe('/url'); - expect(headers['Custom']).toBe('Header'); }); - it('should have .delete()', function() { + it('should have delete()', function() { + $httpBackend.expect('DELETE', '/url').respond(''); $http['delete']('/url'); - - expect(method).toBe('DELETE'); - expect(url).toBe('/url'); }); - it('.delete() should allow config param', function() { + it('delete() should allow config param', function() { + $httpBackend.expect('DELETE', '/url', undefined, checkHeader('Custom', 'Header')).respond(''); $http['delete']('/url', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('DELETE'); - expect(url).toBe('/url'); - expect(headers['Custom']).toBe('Header'); }); - it('should have .head()', function() { + it('should have head()', function() { + $httpBackend.expect('HEAD', '/url').respond(''); $http.head('/url'); - - expect(method).toBe('HEAD'); - expect(url).toBe('/url'); }); - it('.head() should allow config param', function() { + it('head() should allow config param', function() { + $httpBackend.expect('HEAD', '/url', undefined, checkHeader('Custom', 'Header')).respond(''); $http.head('/url', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('HEAD'); - expect(url).toBe('/url'); - expect(headers['Custom']).toBe('Header'); }); - it('should have .patch()', function() { + it('should have patch()', function() { + $httpBackend.expect('PATCH', '/url').respond(''); $http.patch('/url'); - - expect(method).toBe('PATCH'); - expect(url).toBe('/url'); }); - it('.patch() should allow config param', function() { + it('patch() should allow config param', function() { + $httpBackend.expect('PATCH', '/url', undefined, checkHeader('Custom', 'Header')).respond(''); $http.patch('/url', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('PATCH'); - expect(url).toBe('/url'); - expect(headers['Custom']).toBe('Header'); }); - it('should have .post()', function() { + it('should have post()', function() { + $httpBackend.expect('POST', '/url', 'some-data').respond(''); $http.post('/url', 'some-data'); - - expect(method).toBe('POST'); - expect(url).toBe('/url'); - expect(data).toBe('some-data'); }); - it('.post() should allow config param', function() { + it('post() should allow config param', function() { + $httpBackend.expect('POST', '/url', 'some-data', checkHeader('Custom', 'Header')).respond(''); $http.post('/url', 'some-data', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('POST'); - expect(url).toBe('/url'); - expect(data).toBe('some-data'); - expect(headers['Custom']).toBe('Header'); }); - it('should have .put()', function() { + it('should have put()', function() { + $httpBackend.expect('PUT', '/url', 'some-data').respond(''); $http.put('/url', 'some-data'); - - expect(method).toBe('PUT'); - expect(url).toBe('/url'); - expect(data).toBe('some-data'); }); - it('.put() should allow config param', function() { + it('put() should allow config param', function() { + $httpBackend.expect('PUT', '/url', 'some-data', checkHeader('Custom', 'Header')).respond(''); $http.put('/url', 'some-data', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('PUT'); - expect(url).toBe('/url'); - expect(data).toBe('some-data'); - expect(headers['Custom']).toBe('Header'); }); - it('should have .jsonp()', function() { + it('should have jsonp()', function() { + $httpBackend.expect('JSONP', '/url').respond(''); $http.jsonp('/url'); - - expect(method).toBe('JSONP'); - expect(url).toBe('/url'); }); - it('.jsonp() should allow config param', function() { + it('jsonp() should allow config param', function() { + $httpBackend.expect('JSONP', '/url', undefined, checkHeader('Custom', 'Header')).respond(''); $http.jsonp('/url', {headers: {'Custom': 'Header'}}); - - expect(method).toBe('JSONP'); - expect(url).toBe('/url'); - expect(headers['Custom']).toBe('Header'); }); }); @@ -454,7 +408,14 @@ describe('$http', function() { describe('abort', function() { - beforeEach(doCommonXhr); + var future, rawXhrObject; + + beforeEach(function() { + $httpBackend.when('GET', '/url').then(''); + future = $http({method: 'GET', url: '/url'}); + rawXhrObject = MockXhr.$$lastInstance; + spyOn(rawXhrObject, 'abort'); + }); it('should return itself to allow chaining', function() { expect(future.abort()).toBe(future); @@ -468,7 +429,7 @@ describe('$http', function() { it('should not abort already finished request', function() { - respond(200, 'content'); + $httpBackend.flush(); future.abort(); expect(rawXhrObject.abort).not.toHaveBeenCalled(); @@ -478,31 +439,33 @@ describe('$http', function() { describe('retry', function() { + var future; + + beforeEach(function() { + $httpBackend.expect('HEAD', '/url-x').respond(''); + future = $http({method: 'HEAD', url: '/url-x'}).on('2xx', callback); + }); + it('should retry last request with same callbacks', function() { - doCommonXhr('HEAD', '/url-x'); - respond(200, ''); - $browser.xhr.reset(); - onSuccess.reset(); + $httpBackend.flush(); + callback.reset(); + $httpBackend.expect('HEAD', '/url-x').respond(''); future.retry(); - expect($browser.xhr).toHaveBeenCalledOnce(); - expect(method).toBe('HEAD'); - expect(url).toBe('/url-x'); - - respond(200, 'body'); - expect(onSuccess).toHaveBeenCalledOnce(); + $httpBackend.flush(); + expect(callback).toHaveBeenCalledOnce(); }); it('should return itself to allow chaining', function() { - doCommonXhr(); - respond(200, ''); + $httpBackend.flush(); + + $httpBackend.expect('HEAD', '/url-x').respond(''); expect(future.retry()).toBe(future); }); it('should throw error when pending request', function() { - doCommonXhr(); expect(future.retry).toThrow('Can not retry request. Abort pending request first.'); }); }); @@ -510,98 +473,92 @@ describe('$http', function() { describe('on', function() { - var callback; + var future; + + function expectToMatch(status, pattern) { + expectToNotMatch(status, pattern, true); + } + + function expectToNotMatch(status, pattern, match) { + callback.reset(); + future = $http({method: 'GET', url: '/' + status}); + future.on(pattern, callback); + $httpBackend.flush(); + + if (match) expect(callback).toHaveBeenCalledOnce(); + else expect(callback).not.toHaveBeenCalledOnce(); + } beforeEach(function() { - future = $http({method: 'GET', url: '/url'}); - callback = jasmine.createSpy('callback'); + $httpBackend.when('GET').then(function(m, url) { + return [parseInt(url.substr(1)), '', {}]; + }); }); it('should return itself to allow chaining', function() { + future = $http({method: 'GET', url: '/url'}); expect(future.on('200', noop)).toBe(future); }); it('should call exact status code callback', function() { - future.on('205', callback); - respond(205, ''); - - expect(callback).toHaveBeenCalledOnce(); + expectToMatch(205, '205'); }); it('should match 2xx', function() { - future.on('2xx', callback); - - respond(200, ''); - respond(201, ''); - respond(266, ''); + expectToMatch(200, '2xx'); + expectToMatch(201, '2xx'); + expectToMatch(266, '2xx'); - respond(400, ''); - respond(300, ''); - - expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(3); + expectToNotMatch(400, '2xx'); + expectToNotMatch(300, '2xx'); }); it('should match 20x', function() { - future.on('20x', callback); - - respond(200, ''); - respond(201, ''); - respond(205, ''); - - respond(400, ''); - respond(300, ''); - respond(210, ''); - respond(255, ''); + expectToMatch(200, '20x'); + expectToMatch(201, '20x'); + expectToMatch(205, '20x'); - expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(3); + expectToNotMatch(210, '20x'); + expectToNotMatch(301, '20x'); + expectToNotMatch(404, '20x'); + expectToNotMatch(501, '20x'); }); it('should match 2x1', function() { - future.on('2x1', callback); - - respond(201, ''); - respond(211, ''); - respond(251, ''); - - respond(400, ''); - respond(300, ''); - respond(210, ''); - respond(255, ''); + expectToMatch(201, '2x1'); + expectToMatch(211, '2x1'); + expectToMatch(251, '2x1'); - expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(3); + expectToNotMatch(210, '2x1'); + expectToNotMatch(301, '2x1'); + expectToNotMatch(400, '2x1'); }); it('should match xxx', function() { - future.on('xxx', callback); - - respond(201, ''); - respond(211, ''); - respond(251, ''); - respond(404, ''); - respond(501, ''); - - expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(5); + expectToMatch(200, 'xxx'); + expectToMatch(210, 'xxx'); + expectToMatch(301, 'xxx'); + expectToMatch(406, 'xxx'); + expectToMatch(510, 'xxx'); }); it('should call all matched callbacks', function() { var no = jasmine.createSpy('wrong'); - future.on('xxx', callback); - future.on('2xx', callback); - future.on('205', callback); - future.on('3xx', no); - future.on('2x1', no); - future.on('4xx', no); - respond(205, ''); + $http({method: 'GET', url: '/205'}) + .on('xxx', callback) + .on('2xx', callback) + .on('205', callback) + .on('3xx', no) + .on('2x1', no) + .on('4xx', no); + + $httpBackend.flush(); expect(callback).toHaveBeenCalled(); expect(callback.callCount).toBe(3); @@ -610,98 +567,66 @@ describe('$http', function() { it('should allow list of status patterns', function() { - future.on('2xx,3xx', callback); - - respond(405, ''); - expect(callback).not.toHaveBeenCalled(); - - respond(201); - expect(callback).toHaveBeenCalledOnce(); - - respond(301); - expect(callback.callCount).toBe(2); + expectToMatch(201, '2xx,3xx'); + expectToMatch(301, '2xx,3xx'); + expectToNotMatch(405, '2xx,3xx'); }); it('should preserve the order of listeners', function() { var log = ''; - future.on('2xx', function() {log += '1';}); - future.on('201', function() {log += '2';}); - future.on('2xx', function() {log += '3';}); - respond(201); + $http({method: 'GET', url: '/201'}) + .on('2xx', function() {log += '1';}) + .on('201', function() {log += '2';}) + .on('2xx', function() {log += '3';}); + + $httpBackend.flush(); expect(log).toBe('123'); }); it('should know "success" alias', function() { - future.on('success', callback); - respond(200, ''); - expect(callback).toHaveBeenCalledOnce(); - - callback.reset(); - respond(201, ''); - expect(callback).toHaveBeenCalledOnce(); - - callback.reset(); - respond(250, ''); - expect(callback).toHaveBeenCalledOnce(); + expectToMatch(200, 'success'); + expectToMatch(201, 'success'); + expectToMatch(250, 'success'); - callback.reset(); - respond(404, ''); - respond(501, ''); - expect(callback).not.toHaveBeenCalled(); + expectToNotMatch(403, 'success'); + expectToNotMatch(501, 'success'); }); it('should know "error" alias', function() { - future.on('error', callback); - respond(401, ''); - expect(callback).toHaveBeenCalledOnce(); - - callback.reset(); - respond(500, ''); - expect(callback).toHaveBeenCalledOnce(); + expectToMatch(401, 'error'); + expectToMatch(500, 'error'); + expectToMatch(0, 'error'); - callback.reset(); - respond(0, ''); - expect(callback).toHaveBeenCalledOnce(); - - callback.reset(); - respond(201, ''); - respond(200, ''); - respond(300, ''); - expect(callback).not.toHaveBeenCalled(); + expectToNotMatch(201, 'error'); + expectToNotMatch(200, 'error'); }); it('should know "always" alias', function() { - future.on('always', callback); - respond(201, ''); - respond(200, ''); - respond(300, ''); - respond(401, ''); - respond(502, ''); - respond(0, ''); - respond(-1, ''); - respond(-2, ''); - - expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(8); + expectToMatch(200, 'always'); + expectToMatch(201, 'always'); + expectToMatch(250, 'always'); + expectToMatch(300, 'always'); + expectToMatch(302, 'always'); + expectToMatch(404, 'always'); + expectToMatch(501, 'always'); + expectToMatch(0, 'always'); + expectToMatch(-1, 'always'); + expectToMatch(-2, 'always'); }); it('should call "xxx" when 0 status code', function() { - future.on('xxx', callback); - respond(0, ''); - expect(callback).toHaveBeenCalledOnce(); + expectToMatch(0, 'xxx'); }); it('should not call "2xx" when 0 status code', function() { - future.on('2xx', callback); - respond(0, ''); - expect(callback).not.toHaveBeenCalled(); + expectToNotMatch(0, '2xx'); }); it('should normalize internal statuses -1, -2 to 0', function() { @@ -709,36 +634,27 @@ describe('$http', function() { expect(status).toBe(0); }); - future.on('xxx', callback); - respond(-1, ''); - respond(-2, ''); + $http({method: 'GET', url: '/0'}).on('xxx', callback); + $http({method: 'GET', url: '/-1'}).on('xxx', callback); + $http({method: 'GET', url: '/-2'}).on('xxx', callback); + $httpBackend.flush(); expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(2); + expect(callback.callCount).toBe(3); }); it('should match "timeout" when -1 internal status', function() { - future.on('timeout', callback); - respond(-1, ''); - - expect(callback).toHaveBeenCalledOnce(); + expectToMatch(-1, 'timeout'); }); it('should match "abort" when 0 status', function() { - future.on('abort', callback); - respond(0, ''); - - expect(callback).toHaveBeenCalledOnce(); + expectToMatch(0, 'abort'); }); it('should match "error" when 0, -1, or -2', function() { - future.on('error', callback); - respond(0, ''); - respond(-1, ''); - respond(-2, ''); - - expect(callback).toHaveBeenCalled(); - expect(callback.callCount).toBe(3); + expectToMatch(0, 'error'); + expectToMatch(-1, 'error'); + expectToMatch(-2, 'error'); }); }); }); @@ -746,29 +662,28 @@ describe('$http', function() { describe('scope.$apply', function() { - beforeEach(doCommonXhr); - it('should $apply after success callback', function() { - respond(200, ''); + $httpBackend.when('GET').then(200); + $http({method: 'GET', url: '/some'}); + $httpBackend.flush(); expect(scope.$apply).toHaveBeenCalledOnce(); }); it('should $apply after error callback', function() { - respond(404, ''); + $httpBackend.when('GET').then(404); + $http({method: 'GET', url: '/some'}); + $httpBackend.flush(); expect(scope.$apply).toHaveBeenCalledOnce(); }); it('should $apply even if exception thrown during callback', function() { - onSuccess.andThrow('error in callback'); - onError.andThrow('error in callback'); - - respond(200, ''); - expect(scope.$apply).toHaveBeenCalledOnce(); + $httpBackend.when('GET').then(200); + callback.andThrow('error in callback'); - scope.$apply.reset(); - respond(400, ''); + $http({method: 'GET', url: '/some'}).on('200', callback); + $httpBackend.flush(); expect(scope.$apply).toHaveBeenCalledOnce(); $exceptionHandler.errors = []; @@ -783,14 +698,14 @@ describe('$http', function() { describe('default', function() { it('should transform object into json', function() { + $httpBackend.expect('POST', '/url', '{"one":"two"}').respond(''); $http({method: 'POST', url: '/url', data: {one: 'two'}}); - expect(data).toBe('{"one":"two"}'); }); it('should ignore strings', function() { + $httpBackend.expect('POST', '/url', 'string-data').respond(''); $http({method: 'POST', url: '/url', data: 'string-data'}); - expect(data).toBe('string-data'); }); }); }); @@ -801,40 +716,47 @@ describe('$http', function() { describe('default', function() { it('should deserialize json objects', function() { - doCommonXhr(); - respond(200, '{"foo":"bar","baz":23}'); + $httpBackend.expect('GET', '/url').respond('{"foo":"bar","baz":23}'); + $http({method: 'GET', url: '/url'}).on('200', callback); + $httpBackend.flush(); - expect(onSuccess.mostRecentCall.args[0]).toEqual({foo: 'bar', baz: 23}); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toEqual({foo: 'bar', baz: 23}); }); it('should deserialize json arrays', function() { - doCommonXhr(); - respond(200, '[1, "abc", {"foo":"bar"}]'); + $httpBackend.expect('GET', '/url').respond('[1, "abc", {"foo":"bar"}]'); + $http({method: 'GET', url: '/url'}).on('200', callback); + $httpBackend.flush(); - expect(onSuccess.mostRecentCall.args[0]).toEqual([1, 'abc', {foo: 'bar'}]); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toEqual([1, 'abc', {foo: 'bar'}]); }); it('should deserialize json with security prefix', function() { - doCommonXhr(); - respond(200, ')]}\',\n[1, "abc", {"foo":"bar"}]'); + $httpBackend.expect('GET', '/url').respond(')]}\',\n[1, "abc", {"foo":"bar"}]'); + $http({method: 'GET', url: '/url'}).on('200', callback); + $httpBackend.flush(); - expect(onSuccess.mostRecentCall.args[0]).toEqual([1, 'abc', {foo:'bar'}]); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toEqual([1, 'abc', {foo:'bar'}]); }); }); + it('should pipeline more functions', function() { function first(d) {return d + '1';} function second(d) {return d + '2';} - onSuccess = jasmine.createSpy('onSuccess'); - $http({method: 'POST', url: '/url', data: '0', transformResponse: [first, second]}) - .on('200', onSuccess); + $httpBackend.expect('POST', '/url').respond('0'); + $http({method: 'POST', url: '/url', transformResponse: [first, second]}) + .on('200', callback); + $httpBackend.flush(); - respond(200, '0'); - expect(onSuccess).toHaveBeenCalledOnce(); - expect(onSuccess.mostRecentCall.args[0]).toBe('012'); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe('012'); }); }); }); @@ -842,95 +764,100 @@ describe('$http', function() { describe('cache', function() { - function doFirstCacheRequest(method, responseStatus) { - onSuccess = jasmine.createSpy('on200'); - $http({method: method || 'get', url: '/url', cache: true}); - respond(responseStatus || 200, 'content'); - $browser.xhr.reset(); + function doFirstCacheRequest(method, respStatus, headers) { + $httpBackend.expect(method || 'GET', '/url').respond(respStatus || 200, 'content', headers); + $http({method: method || 'GET', url: '/url', cache: true}); + $httpBackend.flush(); } it('should cache GET request', function() { doFirstCacheRequest(); - $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); + $http({method: 'get', url: '/url', cache: true}).on('200', callback); $browser.defer.flush(); - expect(onSuccess).toHaveBeenCalledOnce(); - expect(onSuccess.mostRecentCall.args[0]).toBe('content'); - expect($browser.xhr).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe('content'); }); it('should always call callback asynchronously', function() { doFirstCacheRequest(); + $http({method: 'get', url: '/url', cache: true}).on('200', callback); - $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); - expect(onSuccess).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalledOnce(); }); it('should not cache POST request', function() { - doFirstCacheRequest('post'); + doFirstCacheRequest('POST'); - $http({method: 'post', url: '/url', cache: true}).on('200', onSuccess); - $browser.defer.flush(); - expect(onSuccess).not.toHaveBeenCalled(); - expect($browser.xhr).toHaveBeenCalledOnce(); + $httpBackend.expect('POST', '/url').respond('content2'); + $http({method: 'POST', url: '/url', cache: true}).on('200', callback); + $httpBackend.flush(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe('content2'); }); it('should not cache PUT request', function() { - doFirstCacheRequest('put'); + doFirstCacheRequest('PUT'); - $http({method: 'put', url: '/url', cache: true}).on('200', onSuccess); - $browser.defer.flush(); - expect(onSuccess).not.toHaveBeenCalled(); - expect($browser.xhr).toHaveBeenCalledOnce(); + $httpBackend.expect('PUT', '/url').respond('content2'); + $http({method: 'PUT', url: '/url', cache: true}).on('200', callback); + $httpBackend.flush(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe('content2'); }); it('should not cache DELETE request', function() { - doFirstCacheRequest('delete'); + doFirstCacheRequest('DELETE'); - $http({method: 'delete', url: '/url', cache: true}).on('200', onSuccess); - $browser.defer.flush(); - expect(onSuccess).not.toHaveBeenCalled(); - expect($browser.xhr).toHaveBeenCalledOnce(); + $httpBackend.expect('DELETE', '/url').respond(206); + $http({method: 'DELETE', url: '/url', cache: true}).on('206', callback); + $httpBackend.flush(); + + expect(callback).toHaveBeenCalledOnce(); }); it('should not cache non 2xx responses', function() { - doFirstCacheRequest('get', 404); + doFirstCacheRequest('GET', 404); - $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); - $browser.defer.flush(); - expect(onSuccess).not.toHaveBeenCalled(); - expect($browser.xhr).toHaveBeenCalledOnce(); + $httpBackend.expect('GET', '/url').respond('content2'); + $http({method: 'GET', url: '/url', cache: true}).on('200', callback); + $httpBackend.flush(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toBe('content2'); }); it('should cache the headers as well', function() { - doFirstCacheRequest(); - onSuccess.andCallFake(function(r, s, headers) { + doFirstCacheRequest('GET', 200, {'content-encoding': 'gzip', 'server': 'Apache'}); + callback.andCallFake(function(r, s, headers) { expect(headers()).toEqual({'content-encoding': 'gzip', 'server': 'Apache'}); expect(headers('server')).toBe('Apache'); }); - $http({method: 'get', url: '/url', cache: true}).on('200', onSuccess); + $http({method: 'GET', url: '/url', cache: true}).on('200', callback); $browser.defer.flush(); - expect(onSuccess).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledOnce(); }); it('should cache status code as well', function() { - doFirstCacheRequest('get', 201); - onSuccess.andCallFake(function(r, status, h) { + doFirstCacheRequest('GET', 201); + callback.andCallFake(function(r, status, h) { expect(status).toBe(201); }); - $http({method: 'get', url: '/url', cache: true}).on('2xx', onSuccess); + $http({method: 'get', url: '/url', cache: true}).on('2xx', callback); $browser.defer.flush(); - expect(onSuccess).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledOnce(); }); }); @@ -938,29 +865,34 @@ describe('$http', function() { describe('pendingCount', function() { it('should return number of pending requests', function() { + $httpBackend.when('GET').then(200); expect($http.pendingCount()).toBe(0); $http({method: 'get', url: '/some'}); expect($http.pendingCount()).toBe(1); - respond(200, ''); + $httpBackend.flush(); expect($http.pendingCount()).toBe(0); }); it('should decrement the counter when request aborted', function() { + $httpBackend.when('GET').then(0); future = $http({method: 'get', url: '/x'}); expect($http.pendingCount()).toBe(1); + future.abort(); - respond(0, ''); + $httpBackend.flush(); expect($http.pendingCount()).toBe(0); }); it('should decrement the counter when served from cache', function() { + $httpBackend.when('GET').then(200); + $http({method: 'get', url: '/cached', cache: true}); - respond(200, 'content'); + $httpBackend.flush(); expect($http.pendingCount()).toBe(0); $http({method: 'get', url: '/cached', cache: true}); @@ -972,12 +904,13 @@ describe('$http', function() { it('should decrement the counter before firing callbacks', function() { - $http({method: 'get', url: '/cached'}).on('xxx', function() { + $httpBackend.when('GET').then(200); + $http({method: 'get', url: '/url'}).on('xxx', function() { expect($http.pendingCount()).toBe(0); }); expect($http.pendingCount()).toBe(1); - respond(200, 'content'); + $httpBackend.flush(); }); }); }); diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index 2ddb26e168f2..c3bc13335c4b 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -134,14 +134,13 @@ describe("widget", function() { expect($rootScope.$$childHead).toBeFalsy(); })); - it('should do xhr request and cache it', inject(function($rootScope, $browser, $compile) { + it('should do xhr request and cache it', inject(function($rootScope, $httpBackend, $compile) { var element = $compile('')($rootScope); - var $browserXhr = $browser.xhr; - $browserXhr.expectGET('myUrl').respond('my partial'); + $httpBackend.expect('GET', 'myUrl').respond('my partial'); $rootScope.url = 'myUrl'; $rootScope.$digest(); - $browserXhr.flush(); + $httpBackend.flush(); expect(element.text()).toEqual('my partial'); $rootScope.url = null; @@ -155,14 +154,13 @@ describe("widget", function() { })); it('should clear content when error during xhr request', - inject(function($browser, $compile, $rootScope) { + inject(function($httpBackend, $compile, $rootScope) { var element = $compile('content')($rootScope); - var $browserXhr = $browser.xhr; - $browserXhr.expectGET('myUrl').respond(404, ''); + $httpBackend.expect('GET', 'myUrl').respond(404, ''); $rootScope.url = 'myUrl'; $rootScope.$digest(); - $browserXhr.flush(); + $httpBackend.flush(); expect(element.text()).toBe(''); })); @@ -500,33 +498,33 @@ describe("widget", function() { it('should load content via xhr when route changes', - inject(function($rootScope, $compile, $browser, $location, $route) { + inject(function($rootScope, $compile, $httpBackend, $location, $route) { $route.when('/foo', {template: 'myUrl1'}); $route.when('/bar', {template: 'myUrl2'}); expect(element.text()).toEqual(''); $location.path('/foo'); - $browser.xhr.expectGET('myUrl1').respond('
    {{1+3}}
    '); + $httpBackend.expect('GET', 'myUrl1').respond('
    {{1+3}}
    '); $rootScope.$digest(); - $browser.xhr.flush(); + $httpBackend.flush(); expect(element.text()).toEqual('4'); $location.path('/bar'); - $browser.xhr.expectGET('myUrl2').respond('angular is da best'); + $httpBackend.expect('GET', 'myUrl2').respond('angular is da best'); $rootScope.$digest(); - $browser.xhr.flush(); + $httpBackend.flush(); expect(element.text()).toEqual('angular is da best'); })); it('should remove all content when location changes to an unknown route', - inject(function($rootScope, $compile, $location, $browser, $route) { + inject(function($rootScope, $compile, $location, $httpBackend, $route) { $route.when('/foo', {template: 'myUrl1'}); $location.path('/foo'); - $browser.xhr.expectGET('myUrl1').respond('
    {{1+3}}
    '); + $httpBackend.expect('GET', 'myUrl1').respond('
    {{1+3}}
    '); $rootScope.$digest(); - $browser.xhr.flush(); + $httpBackend.flush(); expect(element.text()).toEqual('4'); $location.path('/unknown'); @@ -535,14 +533,14 @@ describe("widget", function() { })); it('should chain scopes and propagate evals to the child scope', - inject(function($rootScope, $compile, $location, $browser, $route) { + inject(function($rootScope, $compile, $location, $httpBackend, $route) { $route.when('/foo', {template: 'myUrl1'}); $rootScope.parentVar = 'parent'; $location.path('/foo'); - $browser.xhr.expectGET('myUrl1').respond('
    {{parentVar}}
    '); + $httpBackend.expect('GET', 'myUrl1').respond('
    {{parentVar}}
    '); $rootScope.$digest(); - $browser.xhr.flush(); + $httpBackend.flush(); expect(element.text()).toEqual('parent'); $rootScope.parentVar = 'new parent'; @@ -551,10 +549,11 @@ describe("widget", function() { })); it('should be possible to nest ng:view in ng:include', inject(function() { + // TODO(vojta): refactor this test var injector = angular.injector('ng', 'ngMock'); var myApp = injector.get('$rootScope'); - var $browser = injector.get('$browser'); - $browser.xhr.expectGET('includePartial.html').respond('view: '); + var $httpBackend = injector.get('$httpBackend'); + $httpBackend.expect('GET', 'includePartial.html').respond('view: '); injector.get('$location').path('/foo'); var $route = injector.get('$route'); @@ -566,9 +565,10 @@ describe("widget", function() { '')(myApp); myApp.$apply(); - $browser.xhr.expectGET('viewPartial.html').respond('content'); + $httpBackend.expect('GET', 'viewPartial.html').respond('content'); + $httpBackend.flush(); myApp.$digest(); - $browser.xhr.flush(); + $httpBackend.flush(); expect(myApp.$element.text()).toEqual('include: view: content'); expect($route.current.template).toEqual('viewPartial.html'); @@ -576,11 +576,10 @@ describe("widget", function() { })); it('should initialize view template after the view controller was initialized even when ' + - 'templates were cached', inject(function($rootScope, $compile, $location, $browser, $route) { + 'templates were cached', inject(function($rootScope, $compile, $location, $httpBackend, $route) { //this is a test for a regression that was introduced by making the ng:view cache sync $route.when('/foo', {controller: ParentCtrl, template: 'viewPartial.html'}); - $rootScope.log = []; function ParentCtrl() { @@ -592,12 +591,12 @@ describe("widget", function() { }; $location.path('/foo'); - $browser.xhr.expectGET('viewPartial.html'). + $httpBackend.expect('GET', 'viewPartial.html'). respond('
    ' + '
    ' + '
    '); $rootScope.$apply(); - $browser.xhr.flush(); + $httpBackend.flush(); expect($rootScope.log).toEqual(['parent', 'init', 'child']); @@ -608,13 +607,12 @@ describe("widget", function() { $rootScope.log = []; $location.path('/foo'); $rootScope.$apply(); - $browser.defer.flush(); expect($rootScope.log).toEqual(['parent', 'init', 'child']); })); it('should discard pending xhr callbacks if a new route is requested before the current ' + - 'finished loading', inject(function($route, $rootScope, $location, $browser) { + 'finished loading', inject(function($route, $rootScope, $location, $httpBackend) { // this is a test for a bad race condition that affected feedback $route.when('/foo', {template: 'myUrl1'}); @@ -623,26 +621,26 @@ describe("widget", function() { expect($rootScope.$element.text()).toEqual(''); $location.path('/foo'); - $browser.xhr.expectGET('myUrl1').respond('
    {{1+3}}
    '); + $httpBackend.expect('GET', 'myUrl1').respond('
    {{1+3}}
    '); $rootScope.$digest(); $location.path('/bar'); - $browser.xhr.expectGET('myUrl2').respond('
    {{1+1}}
    '); + $httpBackend.expect('GET', 'myUrl2').respond('
    {{1+1}}
    '); $rootScope.$digest(); - $browser.xhr.flush(); // now that we have to requests pending, flush! + $httpBackend.flush(); // now that we have to requests pending, flush! expect($rootScope.$element.text()).toEqual('2'); })); it('should clear the content when error during xhr request', - inject(function($route, $location, $rootScope, $browser) { + inject(function($route, $location, $rootScope, $httpBackend) { $route.when('/foo', {controller: noop, template: 'myUrl1'}); $location.path('/foo'); - $browser.xhr.expectGET('myUrl1').respond(404, ''); + $httpBackend.expect('GET', 'myUrl1').respond(404, ''); $rootScope.$element.text('content'); $rootScope.$digest(); - $browser.xhr.flush(); + $httpBackend.flush(); expect($rootScope.$element.text()).toBe(''); })); From ce73a50083f69e0bd2bef264a6012b5b632981b3 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 18 Oct 2011 16:35:32 -0700 Subject: [PATCH 09/28] feat($templateCache): add $templateCache - shared by ng:include, ng:view --- src/AngularPublic.js | 1 + src/service/cacheFactory.js | 7 +++++++ src/widgets.js | 19 +++++++------------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 05d6417f7dc6..addd9a28dad9 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -86,6 +86,7 @@ function ngModule($provide, $injector) { $provide.service('$routeParams', $RouteParamsProvider); $provide.service('$rootScope', $RootScopeProvider); $provide.service('$sniffer', $SnifferProvider); + $provide.service('$templateCache', $TemplateCacheProvider); $provide.service('$window', $WindowProvider); } diff --git a/src/service/cacheFactory.js b/src/service/cacheFactory.js index ccc6931355c7..2d4c87cfaea5 100644 --- a/src/service/cacheFactory.js +++ b/src/service/cacheFactory.js @@ -150,3 +150,10 @@ function $CacheFactoryProvider() { return cacheFactory; }; } + +function $TemplateCacheProvider() { + this.$get = ['$cacheFactory', function($cacheFactory) { + return $cacheFactory('templates'); + }]; +} + diff --git a/src/widgets.js b/src/widgets.js index 419763ca78e3..b6fb81b659c0 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -90,14 +90,12 @@ angularWidget('ng:include', function(element){ this.directives(true); } else { element[0]['ng:compiled'] = true; - return ['$http', '$cacheFactory', '$element', function($http, $cacheFactory, element) { + return ['$http', '$templateCache', '$element', function($http, $cache, element) { var scope = this, changeCounter = 0, releaseScopes = [], childScope, - oldScope, - // TODO(vojta): configure the cache / extract into $tplCache service ? - cache = $cacheFactory.get('templates') || $cacheFactory('templates'); + oldScope; function incrementChange() { changeCounter++;} this.$watch(srcExp, incrementChange); @@ -133,14 +131,14 @@ angularWidget('ng:include', function(element){ releaseScopes.pop().$destroy(); } if (src) { - if ((fromCache = cache.get(src))) { + if ((fromCache = $cache.get(src))) { scope.$evalAsync(function() { updateContent(fromCache); }); } else { $http.get(src).on('success', function(response) { updateContent(response); - cache.put(src, response); + $cache.put(src, response); }).on('error', clearContent); } } else { @@ -573,13 +571,10 @@ angularWidget('ng:view', function(element) { if (!element[0]['ng:compiled']) { element[0]['ng:compiled'] = true; - return ['$http', '$cacheFactory', '$route', '$element', function($http, $cacheFactory, $route, element) { + return ['$http', '$templateCache', '$route', '$element', function($http, $cache, $route, element) { var template; var changeCounter = 0; - // TODO(vojta): configure the cache / extract into $tplCache service ? - var cache = $cacheFactory.get('templates') || $cacheFactory('templates'); - this.$on('$afterRouteChange', function() { changeCounter++; }); @@ -598,7 +593,7 @@ angularWidget('ng:view', function(element) { } if (template) { - if ((fromCache = cache.get(template))) { + if ((fromCache = $cache.get(template))) { scope.$evalAsync(function() { updateContent(fromCache); }); @@ -608,7 +603,7 @@ angularWidget('ng:view', function(element) { // ignore callback if another route change occured since if (newChangeCounter == changeCounter) updateContent(response); - cache.put(template, response); + $cache.put(template, response); }).on('error', clearContent); } } else { From 9a7447e2797851ebb4c4475ef8490751716eac4f Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 23 Aug 2011 22:17:38 +0200 Subject: [PATCH 10/28] feat(mocks.$browser): add simple addJs() method into $browser mock --- src/angular-mocks.js | 7 +++++++ test/angular-mocksSpec.js | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index bc0578f5c581..2aabb96d8cf7 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -300,6 +300,13 @@ angular.module.ngMock.$Browser = function() { self.baseHref = function() { return this.$$baseHref; }; + + self.$$scripts = []; + self.addJs = function(url, domId, done) { + var script = {url: url, id: domId, done: done}; + self.$$scripts.push(script); + return script; + }; } angular.module.ngMock.$Browser.prototype = { diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index 4551d11d655a..183c7d7442b7 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -1,6 +1,32 @@ 'use strict'; describe('mocks', function() { + + describe('$browser', function() { + + describe('addJs', function() { + + it('should store url, id, done', inject(function($browser) { + var url = 'some.js', + id = 'js-id', + done = noop; + + $browser.addJs(url, id, done); + + var script = $browser.$$scripts.shift(); + expect(script.url).toBe(url); + expect(script.id).toBe(id); + expect(script.done).toBe(done); + })); + + + it('should return the script object', inject(function($browser) { + expect($browser.addJs('some.js', null, noop)).toBe($browser.$$scripts[0]); + })); + }); + }); + + describe('TzDate', function() { function minutes(min) { From c19f2deb80d0623da3610f35e1c7f7ebb83bacc3 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 23 Aug 2011 22:19:36 +0200 Subject: [PATCH 11/28] feat($httpBackend): extract $browser.xhr into separate service - remove whole $browser.xhr stuff - remove whole mock $browser.xhr stuff - add $httpBackend service + migrate unit tests from $browser - add temporary API to access $browser's outstandingRequests count --- src/angular-mocks.js | 180 ++++------------------------- src/service/browser.js | 102 +---------------- src/service/httpBackend.js | 84 +++++++++++++- test/service/browserSpecs.js | 197 +------------------------------- test/service/httpBackendSpec.js | 179 +++++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 455 deletions(-) create mode 100644 test/service/httpBackendSpec.js diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 2aabb96d8cf7..00541c8f0704 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -57,6 +57,10 @@ angular.module.ngMock.$Browser = function() { self.$$lastUrl = self.$$url; // used by url polling fn self.pollFns = []; + // TODO(vojta): remove this temporary api + self.$$completeOutstandingRequest = noop; + self.$$incOutstandingRequestCount = noop; + // register url polling fn @@ -73,165 +77,6 @@ angular.module.ngMock.$Browser = function() { return listener; }; - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr - * @methodOf angular.module.ngMock.$browser - * - * @description - * Generic method for training browser to expect a request in a test and respond to it. - * - * See also convenience methods for browser training: - * - * - {@link #xhr.expectGET} - * - {@link #xhr.expectPOST} - * - {@link #xhr.expectPUT} - * - {@link #xhr.expectDELETE} - * - {@link #xhr.expectJSON} - * - * To flush pending requests in tests use - * {@link #xhr.flush}. - * - * @param {string} method Expected HTTP method. - * @param {string} url Url path for which a request is expected. - * @param {(object|string)=} data Expected body of the (POST) HTTP request. - * @param {function(number, *)} callback Callback to call when response is flushed. - * @param {object} headers Key-value pairs of expected headers. - * @returns {object} Response configuration object. You can call its `respond()` method to - * configure what should the browser mock return when the response is - * {@link #xhr.flush flushed}. - */ - self.xhr = function(method, url, data, callback, headers) { - headers = headers || {}; - if (data && angular.isObject(data)) data = angular.toJson(data); - if (data && angular.isString(data)) url += "|" + data; - var expect = expectations[method] || {}; - var expectation = expect[url]; - if (!expectation) { - throw new Error("Unexpected request for method '" + method + "' and url '" + url + "'."); - } - requests.push(function() { - angular.forEach(expectation.headers, function(value, key){ - if (headers[key] !== value) { - throw new Error("Missing HTTP request header: " + key + ": " + value); - } - }); - callback(expectation.code, expectation.response); - }); - // TODO(vojta): return mock request object - }; - self.xhr.expectations = expectations; - self.xhr.requests = requests; - self.xhr.expect = function(method, url, data, headers) { - if (data && angular.isObject(data)) data = angular.toJson(data); - if (data && angular.isString(data)) url += "|" + data; - var expect = expectations[method] || (expectations[method] = {}); - return { - respond: function(code, response) { - if (!angular.isNumber(code)) { - response = code; - code = 200; - } - expect[url] = {code:code, response:response, headers: headers || {}}; - } - }; - }; - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr.expectGET - * @methodOf angular.module.ngMock.$browser - * - * @description - * Trains browser to expect a `GET` request and respond to it. - * - * @param {string} url Url path for which a request is expected. - * @returns {object} Response configuration object. You can call its `respond()` method to - * configure what should the browser mock return when the response is - * {@link angular.module.ngMock.$browser#xhr.flush flushed}. - */ - self.xhr.expectGET = angular.bind(self, self.xhr.expect, 'GET'); - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr.expectPOST - * @methodOf angular.module.ngMock.$browser - * - * @description - * Trains browser to expect a `POST` request and respond to it. - * - * @param {string} url Url path for which a request is expected. - * @returns {object} Response configuration object. You can call its `respond()` method to - * configure what should the browser mock return when the response is - * {@link angular.module.ngMock.$browser#xhr.flush flushed}. - */ - self.xhr.expectPOST = angular.bind(self, self.xhr.expect, 'POST'); - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr.expectDELETE - * @methodOf angular.module.ngMock.$browser - * - * @description - * Trains browser to expect a `DELETE` request and respond to it. - * - * @param {string} url Url path for which a request is expected. - * @returns {object} Response configuration object. You can call its `respond()` method to - * configure what should the browser mock return when the response is - * {@link angular.module.ngMock.$browser#xhr.flush flushed}. - */ - self.xhr.expectDELETE = angular.bind(self, self.xhr.expect, 'DELETE'); - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr.expectPUT - * @methodOf angular.module.ngMock.$browser - * - * @description - * Trains browser to expect a `PUT` request and respond to it. - * - * @param {string} url Url path for which a request is expected. - * @returns {object} Response configuration object. You can call its `respond()` method to - * configure what should the browser mock return when the response is - * {@link angular.module.ngMock.$browser#xhr.flush flushed}. - */ - self.xhr.expectPUT = angular.bind(self, self.xhr.expect, 'PUT'); - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr.expectJSON - * @methodOf angular.module.ngMock.$browser - * - * @description - * Trains browser to expect a `JSON` request and respond to it. - * - * @param {string} url Url path for which a request is expected. - * @returns {object} Response configuration object. You can call its `respond()` method to - * configure what should the browser mock return when the response is - * {@link angular.module.ngMock.$browser#xhr.flush flushed}. - */ - self.xhr.expectJSON = angular.bind(self, self.xhr.expect, 'JSON'); - - /** - * @ngdoc method - * @name angular.module.ngMock.$browser#xhr.flush - * @methodOf angular.module.ngMock.$browser - * - * @description - * Flushes all pending requests and executes xhr callbacks with the trained response as the - * argument. - */ - self.xhr.flush = function() { - if (requests.length == 0) { - throw new Error("No xhr requests to be flushed!"); - } - - while(requests.length) { - requests.pop()(); - } - }; - self.cookieHash = {}; self.lastCookieHash = {}; self.deferredFns = []; @@ -871,9 +716,24 @@ function MockHttpExpectation(method, url, data, headers) { function MockXhr() { - // hack for testing $http + // hack for testing $http, $httpBackend MockXhr.$$lastInstance = this; + this.open = function(method, url, async) { + this.$$method = method; + this.$$url = url; + this.$$async = async; + this.$$headers = {}; + }; + + this.send = function(data) { + this.$$data = data; + }; + + this.setRequestHeader = function(key, value) { + this.$$headers[key] = value; + }; + this.getResponseHeader = function(name) { return this.$$headers[name]; }; diff --git a/src/service/browser.js b/src/service/browser.js index 74bea44caf1b..f6b1d01c50cc 100644 --- a/src/service/browser.js +++ b/src/service/browser.js @@ -1,16 +1,5 @@ 'use strict'; -////////////////////////////// -// Browser -////////////////////////////// -var XHR = window.XMLHttpRequest || function() { - try { return new ActiveXObject("Msxml2.XMLHTTP.6.0"); } catch (e1) {} - try { return new ActiveXObject("Msxml2.XMLHTTP.3.0"); } catch (e2) {} - try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e3) {} - throw new Error("This browser does not support XMLHttpRequest."); -}; - - /** * @ngdoc object * @name angular.module.ng.$browser @@ -33,7 +22,7 @@ var XHR = window.XMLHttpRequest || function() { * @param {object} $log console.log or an object with the same interface. * @param {object} $sniffer $sniffer service */ -function Browser(window, document, body, XHR, $log, $sniffer) { +function Browser(window, document, body, $log, $sniffer) { var self = this, rawDocument = document[0], location = window.location, @@ -44,13 +33,12 @@ function Browser(window, document, body, XHR, $log, $sniffer) { self.isMock = false; - ////////////////////////////////////////////////////////////// - // XHR API - ////////////////////////////////////////////////////////////// - var idCounter = 0; var outstandingRequestCount = 0; var outstandingRequestCallbacks = []; + // TODO(vojta): remove this temporary api + self.$$completeOutstandingRequest = completeOutstandingRequest; + self.$$incOutstandingRequestCount = function() { outstandingRequestCount++; }; /** * Executes the `fn` function(supports currying) and decrements the `outstandingRequestCallbacks` @@ -73,88 +61,6 @@ function Browser(window, document, body, XHR, $log, $sniffer) { } } - // normalize IE bug (http://bugs.jquery.com/ticket/1450) - function fixStatus(status) { - return status == 1223 ? 204 : status; - } - - /** - * @ngdoc method - * @name angular.module.ng.$browser#xhr - * @methodOf angular.module.ng.$browser - * - * @param {string} method Requested method (get|post|put|delete|head|json) - * @param {string} url Requested url - * @param {?string} post Post data to send (null if nothing to post) - * @param {function(number, string)} callback Function that will be called on response - * @param {object=} header additional HTTP headers to send with XHR. - * Standard headers are: - *
      - *
    • Content-Type: application/x-www-form-urlencoded
    • - *
    • Accept: application/json, text/plain, */*
    • - *
    • X-Requested-With: XMLHttpRequest
    • - *
    - * - * @param {number=} timeout Timeout in ms, when the request will be aborted - * @returns {XMLHttpRequest|undefined} Raw XMLHttpRequest object or undefined when JSONP method - * - * @description - * Send ajax request - * - * TODO(vojta): change signature of this method to (method, url, data, headers, callback) - */ - self.xhr = function(method, url, post, callback, headers, timeout) { - outstandingRequestCount ++; - if (lowercase(method) == 'jsonp') { - var callbackId = ("angular_" + Math.random() + '_' + (idCounter++)).replace(/\d\./, ''); - window[callbackId] = function(data) { - window[callbackId].data = data; - }; - - var script = self.addJs(url.replace('JSON_CALLBACK', callbackId), function() { - if (window[callbackId].data) { - completeOutstandingRequest(callback, 200, window[callbackId].data); - } else { - completeOutstandingRequest(callback, -2); - } - delete window[callbackId]; - body[0].removeChild(script); - }); - } else { - var xhr = new XHR(); - xhr.open(method, url, true); - forEach(headers, function(value, key) { - if (value) xhr.setRequestHeader(key, value); - }); - - var status; - xhr.send(post || ''); - - // IE6, IE7 bug - does sync when serving from cache - if (xhr.readyState == 4) { - setTimeout(function() { - completeOutstandingRequest(callback, fixStatus(status || xhr.status), xhr.responseText); - }, 0); - } else { - xhr.onreadystatechange = function() { - if (xhr.readyState == 4) { - completeOutstandingRequest(callback, fixStatus(status || xhr.status), - xhr.responseText); - } - }; - } - - if (timeout > 0) { - setTimeout(function() { - status = -1; - xhr.abort(); - }, timeout); - } - - return xhr; - } - }; - /** * @private * Note: this method is used only by scenario runner diff --git a/src/service/httpBackend.js b/src/service/httpBackend.js index af3de9701e51..287009403fc0 100644 --- a/src/service/httpBackend.js +++ b/src/service/httpBackend.js @@ -1,6 +1,86 @@ +var XHR = window.XMLHttpRequest || function() { + try { return new ActiveXObject("Msxml2.XMLHTTP.6.0"); } catch (e1) {} + try { return new ActiveXObject("Msxml2.XMLHTTP.3.0"); } catch (e2) {} + try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e3) {} + throw new Error("This browser does not support XMLHttpRequest."); +}; + + +/** + * @ngdoc object + * @name angular.module.ng.$httpBackend + * @requires $browser + * @requires $window + * @requires $document + * + * @description + */ function $HttpBackendProvider() { - this.$get = ['$browser', function($browser) { - return $browser.xhr; + this.$get = ['$browser', '$window', '$document', function($browser, $window, $document) { + return createHttpBackend($browser, XHR, $browser.defer, $window, $document[0].body); }]; } +function createHttpBackend($browser, XHR, $browserDefer, $window, body) { + var idCounter = 0; + + function completeRequest(callback, status, response) { + // normalize IE bug (http://bugs.jquery.com/ticket/1450) + callback(status == 1223 ? 204 : status, response); + $browser.$$completeOutstandingRequest(noop); + } + + // TODO(vojta): fix the signature + return function(method, url, post, callback, headers, timeout) { + $browser.$$incOutstandingRequestCount(); + + if (lowercase(method) == 'jsonp') { + var callbackId = ('angular_' + Math.random() + '_' + (idCounter++)).replace(/\d\./, ''); + $window[callbackId] = function(data) { + $window[callbackId].data = data; + }; + + var script = $browser.addJs(url.replace('JSON_CALLBACK', callbackId), null, function() { + if ($window[callbackId].data) { + completeRequest(callback, 200, $window[callbackId].data); + } else { + completeRequest(callback, -2); + } + delete $window[callbackId]; + body.removeChild(script); + }); + } else { + var xhr = new XHR(); + xhr.open(method, url, true); + forEach(headers, function(value, key) { + if (value) xhr.setRequestHeader(key, value); + }); + + var status; + xhr.send(post || ''); + + // IE6, IE7 bug - does sync when serving from cache + if (xhr.readyState == 4) { + $browserDefer(function() { + completeRequest(callback, status || xhr.status, xhr.responseText); + }, 0); + } else { + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + completeRequest(callback, status || xhr.status, xhr.responseText); + } + }; + } + + if (timeout > 0) { + $browserDefer(function() { + status = -1; + xhr.abort(); + }, timeout); + } + + return xhr; + } + }; +} + diff --git a/test/service/browserSpecs.js b/test/service/browserSpecs.js index 2ec000f46412..4563d14b4dc0 100644 --- a/test/service/browserSpecs.js +++ b/test/service/browserSpecs.js @@ -48,34 +48,17 @@ function MockWindow() { describe('browser', function() { - var browser, fakeWindow, xhr, logs, scripts, removedScripts, sniffer; + var browser, fakeWindow, logs, scripts, removedScripts, sniffer; beforeEach(function() { scripts = []; removedScripts = []; - xhr = null; sniffer = {history: true, hashchange: true}; fakeWindow = new MockWindow(); var fakeBody = [{appendChild: function(node){scripts.push(node);}, removeChild: function(node){removedScripts.push(node);}}]; - var FakeXhr = function() { - xhr = this; - this.open = function(method, url, async){ - xhr.method = method; - xhr.url = url; - xhr.async = async; - xhr.headers = {}; - }; - this.setRequestHeader = function(key, value){ - xhr.headers[key] = value; - }; - this.send = function(post){ - xhr.post = post; - }; - }; - logs = {log:[], warn:[], info:[], error:[]}; var fakeLog = {log: function() { logs.log.push(slice.call(arguments)); }, @@ -83,8 +66,7 @@ describe('browser', function() { info: function() { logs.info.push(slice.call(arguments)); }, error: function() { logs.error.push(slice.call(arguments)); }}; - browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr, - fakeLog, sniffer); + browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, fakeLog, sniffer); }); it('should contain cookie cruncher', function() { @@ -97,183 +79,8 @@ describe('browser', function() { browser.notifyWhenNoOutstandingRequests(callback); expect(callback).toHaveBeenCalled(); }); - - it('should queue callbacks with outstanding requests', function() { - var callback = jasmine.createSpy('callback'); - browser.xhr('GET', '/url', null, noop); - browser.notifyWhenNoOutstandingRequests(callback); - expect(callback).not.toHaveBeenCalled(); - - xhr.readyState = 4; - xhr.onreadystatechange(); - expect(callback).toHaveBeenCalled(); - }); }); - describe('xhr', function() { - describe('JSONP', function() { - var log; - - function callback(code, data) { - log += code + ':' + data + ';'; - } - - beforeEach(function() { - log = ""; - }); - - - // We don't have unit tests for IE because script.readyState is readOnly. - // Instead we run e2e tests on all browsers - see e2e for $http. - if (!msie) { - - it('should add script tag for JSONP request', function() { - var notify = jasmine.createSpy('notify'); - browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); - browser.notifyWhenNoOutstandingRequests(notify); - expect(notify).not.toHaveBeenCalled(); - expect(scripts.length).toEqual(1); - var script = scripts[0]; - var url = script.src.split('?cb='); - expect(url[0]).toEqual('http://example.org/path'); - expect(typeof fakeWindow[url[1]]).toEqual('function'); - fakeWindow[url[1]]('data'); - script.onload(); - - expect(notify).toHaveBeenCalled(); - expect(log).toEqual('200:data;'); - expect(scripts).toEqual(removedScripts); - expect(fakeWindow[url[1]]).toBeUndefined(); - }); - - - it('should call callback with status -2 when script fails to load', function() { - browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); - var script = scripts[0]; - expect(typeof script.onload).toBe('function'); - expect(typeof script.onerror).toBe('function'); - script.onerror(); - - expect(log).toEqual('-2:undefined;'); - }); - - - it('should update the outstandingRequests counter for successful requests', function() { - var notify = jasmine.createSpy('notify'); - browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); - browser.notifyWhenNoOutstandingRequests(notify); - expect(notify).not.toHaveBeenCalled(); - - var script = scripts[0]; - var url = script.src.split('?cb='); - fakeWindow[url[1]]('data'); - script.onload(); - - expect(notify).toHaveBeenCalled(); - }); - - - it('should update the outstandingRequests counter for failed requests', function() { - var notify = jasmine.createSpy('notify'); - browser.xhr('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); - browser.notifyWhenNoOutstandingRequests(notify); - expect(notify).not.toHaveBeenCalled(); - - scripts[0].onerror(); - - expect(notify).toHaveBeenCalled(); - }); - } - }); - - - it('should normalize IE\'s 1223 status code into 204', function() { - var callback = jasmine.createSpy('XHR'); - - browser.xhr('GET', 'URL', 'POST', callback); - - xhr.status = 1223; - xhr.readyState = 4; - xhr.onreadystatechange(); - - expect(callback).toHaveBeenCalled(); - expect(callback.argsForCall[0][0]).toEqual(204); - }); - - it('should set only the requested headers', function() { - var code, response, headers = {}; - browser.xhr('POST', 'URL', null, function(c,r){ - code = c; - response = r; - }, {'X-header1': 'value1', 'X-header2': 'value2'}); - - expect(xhr.method).toEqual('POST'); - expect(xhr.url).toEqual('URL'); - expect(xhr.post).toEqual(''); - expect(xhr.headers).toEqual({ - "X-header1":"value1", - "X-header2":"value2" - }); - - xhr.status = 202; - xhr.responseText = 'RESPONSE'; - xhr.readyState = 4; - xhr.onreadystatechange(); - - expect(code).toEqual(202); - expect(response).toEqual('RESPONSE'); - }); - - it('should return raw xhr object', function() { - expect(browser.xhr('GET', '/url', null, noop)).toBe(xhr); - }); - - it('should abort request on timeout', function() { - var callback = jasmine.createSpy('done').andCallFake(function(status, response) { - expect(status).toBe(-1); - }); - - browser.xhr('GET', '/url', null, callback, {}, 2000); - xhr.abort = jasmine.createSpy('xhr.abort'); - - fakeWindow.setTimeout.flush(); - expect(xhr.abort).toHaveBeenCalledOnce(); - - xhr.status = 0; - xhr.readyState = 4; - xhr.onreadystatechange(); - expect(callback).toHaveBeenCalledOnce(); - }); - - it('should be async even if xhr.send() is sync', function() { - // IE6, IE7 is sync when serving from cache - var xhr; - function FakeXhr() { - xhr = this; - this.open = this.setRequestHeader = noop; - this.send = function() { - this.status = 200; - this.responseText = 'response'; - this.readyState = 4; - }; - } - - var callback = jasmine.createSpy('done').andCallFake(function(status, response) { - expect(status).toBe(200); - expect(response).toBe('response'); - }); - - browser = new Browser(fakeWindow, jqLite(window.document), null, FakeXhr, null); - browser.xhr('GET', '/url', null, callback); - expect(callback).not.toHaveBeenCalled(); - - fakeWindow.setTimeout.flush(); - expect(callback).toHaveBeenCalledOnce(); - - (xhr.onreadystatechange || noop)(); - expect(callback).toHaveBeenCalledOnce(); - }); - }); describe('defer', function() { it('should execute fn asynchroniously via setTimeout', function() { diff --git a/test/service/httpBackendSpec.js b/test/service/httpBackendSpec.js new file mode 100644 index 000000000000..e609eea6f175 --- /dev/null +++ b/test/service/httpBackendSpec.js @@ -0,0 +1,179 @@ +describe('$httpBackend', function() { + + var $backend, $browser, $window, + xhr, fakeBody, callback; + + // TODO(vojta): should be replaced by $defer mock + function fakeTimeout(fn, delay) { + fakeTimeout.fns.push(fn); + fakeTimeout.delays.push(delay); + } + + fakeTimeout.fns = []; + fakeTimeout.delays = []; + fakeTimeout.flush = function() { + var len = fakeTimeout.fns.length; + fakeTimeout.delays = []; + while (len--) fakeTimeout.fns.shift()(); + }; + + + beforeEach(inject(function($injector) { + $window = {}; + $browser = $injector.get('$browser'); + fakeBody = {removeChild: jasmine.createSpy('body.removeChild')}; + $backend = createHttpBackend($browser, MockXhr, fakeTimeout, $window, fakeBody); + callback = jasmine.createSpy('done'); + })); + + + it('should do basics - open async xhr and send data', function() { + $backend('GET', '/some-url', 'some-data', noop); + xhr = MockXhr.$$lastInstance; + + expect(xhr.$$method).toBe('GET'); + expect(xhr.$$url).toBe('/some-url'); + expect(xhr.$$data).toBe('some-data'); + expect(xhr.$$async).toBe(true); + }); + + + it('should normalize IE\'s 1223 status code into 204', function() { + callback.andCallFake(function(status) { + expect(status).toBe(204); + }); + + $backend('GET', 'URL', null, callback); + xhr = MockXhr.$$lastInstance; + + xhr.status = 1223; + xhr.readyState = 4; + xhr.onreadystatechange(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should set only the requested headers', function() { + $backend('POST', 'URL', null, noop, {'X-header1': 'value1', 'X-header2': 'value2'}); + xhr = MockXhr.$$lastInstance; + + expect(xhr.$$headers).toEqual({ + 'X-header1': 'value1', + 'X-header2': 'value2' + }); + }); + + + it('should return raw xhr object', function() { + expect($backend('GET', '/url', null, noop)).toBe(MockXhr.$$lastInstance); + }); + + + it('should abort request on timeout', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(-1); + }); + + $backend('GET', '/url', null, callback, {}, 2000); + xhr = MockXhr.$$lastInstance; + spyOn(xhr, 'abort'); + + expect(fakeTimeout.delays[0]).toBe(2000); + + fakeTimeout.flush(); + expect(xhr.abort).toHaveBeenCalledOnce(); + + xhr.status = 0; + xhr.readyState = 4; + xhr.onreadystatechange(); + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should be async even if xhr.send() is sync', function() { + // IE6, IE7 is sync when serving from cache + function SyncXhr() { + xhr = this; + this.open = this.setRequestHeader = noop; + this.send = function() { + this.status = 200; + this.responseText = 'response'; + this.readyState = 4; + }; + } + + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('response'); + }); + + $backend = createHttpBackend($browser, SyncXhr, fakeTimeout); + $backend('GET', '/url', null, callback); + expect(callback).not.toHaveBeenCalled(); + + fakeTimeout.flush(); + expect(callback).toHaveBeenCalledOnce(); + + (xhr.onreadystatechange || noop)(); + expect(callback).toHaveBeenCalledOnce(); + }); + + + describe('JSONP', function() { + + it('should add script tag for JSONP request', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + expect(response).toBe('some-data'); + }); + + $backend('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + expect($browser.$$scripts.length).toBe(1); + + var script = $browser.$$scripts.shift(), + url = script.url.split('?cb='); + + expect(url[0]).toBe('http://example.org/path'); + $window[url[1]]('some-data'); + script.done(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should clean up the callback and remove the script', function() { + $backend('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + expect($browser.$$scripts.length).toBe(1); + + var script = $browser.$$scripts.shift(), + callbackId = script.url.split('?cb=')[1]; + + $window[callbackId]('some-data'); + script.done(); + + expect($window[callbackId]).toBeUndefined(); + expect(fakeBody.removeChild).toHaveBeenCalledOnce(); + expect(fakeBody.removeChild).toHaveBeenCalledWith(script); + }); + + + it('should call callback with status -2 when script fails to load', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(-2); + expect(response).toBeUndefined(); + }); + + $backend('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback); + expect($browser.$$scripts.length).toBe(1); + + $browser.$$scripts.shift().done(); + expect(callback).toHaveBeenCalledOnce(); + }); + + + // TODO(vojta): test whether it fires "async-start" + // TODO(vojta): test whether it fires "async-end" on both success and error + }); +}); + From e02df365eaaab477487e905ebbf56bfb3e008a48 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Mon, 17 Oct 2011 22:52:21 -0700 Subject: [PATCH 12/28] feat($http): expose pendingRequests and configuration object - $http.pendingRequests is now an array of pending requests - each request (its future object) has public property configuration --- src/service/http.js | 41 +++++++++++++++++++--------------------- test/service/httpSpec.js | 30 ++++++++++++++--------------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/service/http.js b/src/service/http.js index 087c3809703b..f06b88fddd3c 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -56,6 +56,8 @@ function transform(data, fns, param) { * @requires $exceptionHandler * @requires $cacheFactory * + * @property {Array.} pendingRequests Array of pending requests. + * * @description */ function $HttpProvider() { @@ -89,28 +91,14 @@ function $HttpProvider() { this.$get = ['$httpBackend', '$browser', '$exceptionHandler', '$cacheFactory', '$rootScope', function($httpBackend, $browser, $exceptionHandler, $cacheFactory, $rootScope) { - var cache = $cacheFactory('$http'), - pendingRequestsCount = 0; + var cache = $cacheFactory('$http'); // the actual service function $http(config) { return new XhrFuture().retry(config); } - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$http#pendingCount - * @methodOf angular.service.$http - * - * @description - * Return number of pending requests - * - * @returns {number} Number of pending requests - */ - $http.pendingCount = function() { - return pendingRequestsCount; - }; + $http.pendingRequests = []; /** * @ngdoc method @@ -236,12 +224,14 @@ function $HttpProvider() { /** * Represents Request object, returned by $http() * - * !!! ACCESS CLOSURE VARS: $httpBackend, $browser, $config, $log, $rootScope, cache, pendingRequestsCount + * !!! ACCESS CLOSURE VARS: + * $httpBackend, $browser, $config, $log, $rootScope, cache, $http.pendingRequests */ function XhrFuture() { - var rawRequest, cfg = {}, callbacks = [], + var rawRequest, parsedHeaders, + cfg = {}, callbacks = [], defHeaders = $config.headers, - parsedHeaders; + self = this; /** * Callback registered to $httpBackend(): @@ -281,9 +271,11 @@ function $HttpProvider() { response = transform(response, cfg.transformResponse || $config.transformResponse, rawRequest); var regexp = statusToRegexp(status), - pattern, callback; + pattern, callback, idx; - pendingRequestsCount--; + // remove from pending requests + if ((idx = indexOf($http.pendingRequests, self)) !== -1) + $http.pendingRequests.splice(idx, 1); // normalize internal statuses to 0 status = Math.max(status, 0); @@ -372,7 +364,7 @@ function $HttpProvider() { rawRequest = $httpBackend(cfg.method, cfg.url, data, done, headers, cfg.timeout); } - pendingRequestsCount++; + $http.pendingRequests.push(self); return this; }; @@ -423,6 +415,11 @@ function $HttpProvider() { return this; }; + + /** + * Configuration object of the request + */ + this.config = cfg; } }]; } diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 75e85359d046..ad83bdf89323 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -862,54 +862,54 @@ describe('$http', function() { }); - describe('pendingCount', function() { + describe('pendingRequests', function() { - it('should return number of pending requests', function() { + it('should be an array of pending requests', function() { $httpBackend.when('GET').then(200); - expect($http.pendingCount()).toBe(0); + expect($http.pendingRequests.length).toBe(0); $http({method: 'get', url: '/some'}); - expect($http.pendingCount()).toBe(1); + expect($http.pendingRequests.length).toBe(1); $httpBackend.flush(); - expect($http.pendingCount()).toBe(0); + expect($http.pendingRequests.length).toBe(0); }); - it('should decrement the counter when request aborted', function() { + it('should remove the request when aborted', function() { $httpBackend.when('GET').then(0); future = $http({method: 'get', url: '/x'}); - expect($http.pendingCount()).toBe(1); + expect($http.pendingRequests.length).toBe(1); future.abort(); $httpBackend.flush(); - expect($http.pendingCount()).toBe(0); + expect($http.pendingRequests.length).toBe(0); }); - it('should decrement the counter when served from cache', function() { + it('should remove the request when served from cache', function() { $httpBackend.when('GET').then(200); $http({method: 'get', url: '/cached', cache: true}); $httpBackend.flush(); - expect($http.pendingCount()).toBe(0); + expect($http.pendingRequests.length).toBe(0); $http({method: 'get', url: '/cached', cache: true}); - expect($http.pendingCount()).toBe(1); + expect($http.pendingRequests.length).toBe(1); $browser.defer.flush(); - expect($http.pendingCount()).toBe(0); + expect($http.pendingRequests.length).toBe(0); }); - it('should decrement the counter before firing callbacks', function() { + it('should remove the request before firing callbacks', function() { $httpBackend.when('GET').then(200); $http({method: 'get', url: '/url'}).on('xxx', function() { - expect($http.pendingCount()).toBe(0); + expect($http.pendingRequests.length).toBe(0); }); - expect($http.pendingCount()).toBe(1); + expect($http.pendingRequests.length).toBe(1); $httpBackend.flush(); }); }); From 821e6c7a9bbd071387e3dca9ec0091facb7ac06b Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 18 Oct 2011 17:03:48 -0700 Subject: [PATCH 13/28] fix($http): allow multiple json vulnerability prefixes We strip out both: )]}', )]}' --- src/service/http.js | 3 ++- test/service/httpSpec.js | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/service/http.js b/src/service/http.js index f06b88fddd3c..3b207a13defc 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -65,7 +65,8 @@ function $HttpProvider() { // transform in-coming reponse data transformResponse: function(data) { if (isString(data)) { - if (/^\)\]\}',\n/.test(data)) data = data.substr(6); + // strip json vulnerability protection prefix + data = data.replace(/^\)\]\}',?\n/, ''); if (/^\s*[\[\{]/.test(data) && /[\}\]]\s*$/.test(data)) data = fromJson(data, true); } diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index ad83bdf89323..b39ac3d7438c 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -743,6 +743,16 @@ describe('$http', function() { expect(callback).toHaveBeenCalledOnce(); expect(callback.mostRecentCall.args[0]).toEqual([1, 'abc', {foo:'bar'}]); }); + + + it('should deserialize json with security prefix ")]}\'"', function() { + $httpBackend.expect('GET', '/url').respond(')]}\'\n\n[1, "abc", {"foo":"bar"}]'); + $http({method: 'GET', url: '/url'}).on('200', callback); + $httpBackend.flush(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toEqual([1, 'abc', {foo:'bar'}]); + }); }); From 5448f048ee90922c49a63ddad499e4eab8e55bf0 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 19 Oct 2011 10:47:17 -0700 Subject: [PATCH 14/28] fix($resource): to work with $http, $httpBackend services Breaks Disabling $resource caching for the moment. --- src/Resource.js | 21 ++-- src/service/resource.js | 13 +-- test/ResourceSpec.js | 253 ++++++++++++++++++++-------------------- 3 files changed, 141 insertions(+), 146 deletions(-) diff --git a/src/Resource.js b/src/Resource.js index 959561e498ec..4bec60f9e3f1 100644 --- a/src/Resource.js +++ b/src/Resource.js @@ -36,8 +36,8 @@ Route.prototype = { } }; -function ResourceFactory(xhr) { - this.xhr = xhr; +function ResourceFactory($http) { + this.$http = $http; } ResourceFactory.DEFAULT_ACTIONS = { @@ -107,11 +107,11 @@ ResourceFactory.prototype = { } var value = this instanceof Resource ? this : (action.isArray ? [] : new Resource(data)); - self.xhr( - action.method, - route.url(extend({}, extractParams(data), action.params || {}, params)), - data, - function(status, response) { + var future = self.$http({ + method: action.method, + url: route.url(extend({}, extractParams(data), action.params || {}, params)), + data: data + }).on('success', function(response, status) { if (response) { if (action.isArray) { value.length = 0; @@ -123,9 +123,10 @@ ResourceFactory.prototype = { } } (success||noop)(value); - }, - error || action.verifyCache, - action.verifyCache); + }); + + if (error) future.on('error', error); + return value; }; diff --git a/src/service/resource.js b/src/service/resource.js index 2082b9ed94e7..8fe27f1b25d5 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -3,15 +3,14 @@ /** * @ngdoc object * @name angular.module.ng.$resource - * @requires $xhr.cache + * @requires $http * * @description * A factory which creates a resource object that lets you interact with * [RESTful](http://en.wikipedia.org/wiki/Representational_State_Transfer) server-side data sources. * * The returned resource object has action methods which provide high-level behaviors without - * the need to interact with the low level {@link angular.module.ng.$xhr $xhr} service or - * raw XMLHttpRequest. + * the need to interact with the low level {@link angular.module.ng.$http $http} service. * * @param {string} url A parameterized URL template with parameters prefixed by `:` as in * `/user/:username`. @@ -57,7 +56,7 @@ * 'remove': {method:'DELETE'}, * 'delete': {method:'DELETE'} }; * - * Calling these methods invoke an {@link angular.module.ng.$xhr} with the specified http method, + * Calling these methods invoke an {@link angular.module.ng.$http} with the specified http method, * destination and parameters. When the data is returned from the server then the object is an * instance of the resource class `save`, `remove` and `delete` actions are available on it as * methods with the `$` prefix. This allows you to easily perform CRUD operations (create, read, @@ -128,7 +127,7 @@ * The object returned from this function execution is a resource "class" which has "static" method * for each action in the definition. * - * Calling these methods invoke `$xhr` on the `url` template with the given `method` and `params`. + * Calling these methods invoke `$http` on the `url` template with the given `method` and `params`. * When the data is returned from the server then the object is an instance of the resource type and * all of the non-GET methods are available with `$` prefix. This allows you to easily support CRUD * operations (create, read, update, delete) on server-side data. @@ -201,8 +200,8 @@ */ function $ResourceProvider() { - this.$get = ['$xhr.cache', function($xhr){ - var resource = new ResourceFactory($xhr); + this.$get = ['$http', function($http) { + var resource = new ResourceFactory($http); return bind(resource, resource.route); }]; } diff --git a/test/ResourceSpec.js b/test/ResourceSpec.js index 46616799d7ab..5d91bf3e1857 100644 --- a/test/ResourceSpec.js +++ b/test/ResourceSpec.js @@ -1,19 +1,14 @@ 'use strict'; -xdescribe("resource", function() { +describe("resource", function() { var resource, CreditCard, callback; function nakedExpect(obj) { return expect(angular.fromJson(angular.toJson(obj))); } - beforeEach(inject( - function($provide) { - $provide.value('$xhr.error', jasmine.createSpy('xhr.error')); - $provide.factory('$xhrError', ['$xhr.error', identity]); - }, - function($xhr) { - resource = new ResourceFactory($xhr); + beforeEach(inject(function($http) { + resource = new ResourceFactory($http); CreditCard = resource.route('/CreditCard/:id:verb', {id:'@id.key'}, { charge:{ method:'POST', @@ -24,6 +19,10 @@ xdescribe("resource", function() { }) ); + afterEach(inject(function($httpBackend) { + $httpBackend.verifyExpectations(); + })); + it("should build resource", function() { expect(typeof CreditCard).toBe('function'); expect(typeof CreditCard.get).toBe('function'); @@ -33,162 +32,183 @@ xdescribe("resource", function() { expect(typeof CreditCard.query).toBe('function'); }); - it('should default to empty parameters', inject(function($browser) { - $browser.xhr.expectGET('URL').respond({}); + it('should default to empty parameters', inject(function($httpBackend) { + $httpBackend.expect('GET', 'URL').respond({}); resource.route('URL').query(); })); - it('should ignore slashes of undefinend parameters', inject(function($browser) { + it('should ignore slashes of undefinend parameters', inject(function($httpBackend) { var R = resource.route('/Path/:a/:b/:c'); - $browser.xhr.expectGET('/Path').respond({}); - $browser.xhr.expectGET('/Path/1').respond({}); - $browser.xhr.expectGET('/Path/2/3').respond({}); - $browser.xhr.expectGET('/Path/4/5/6').respond({}); + + $httpBackend.when('GET').then('{}'); + $httpBackend.expect('GET', '/Path'); + $httpBackend.expect('GET', '/Path/1'); + $httpBackend.expect('GET', '/Path/2/3'); + $httpBackend.expect('GET', '/Path/4/5/6'); + R.get({}); R.get({a:1}); R.get({a:2, b:3}); R.get({a:4, b:5, c:6}); })); - it('should correctly encode url params', inject(function($browser) { + it('should correctly encode url params', inject(function($httpBackend) { var R = resource.route('/Path/:a'); - $browser.xhr.expectGET('/Path/foo%231').respond({}); - $browser.xhr.expectGET('/Path/doh!@foo?bar=baz%231').respond({}); + + $httpBackend.expect('GET', '/Path/foo%231').respond('{}'); + $httpBackend.expect('GET', '/Path/doh!@foo?bar=baz%231').respond('{}'); + R.get({a: 'foo#1'}); R.get({a: 'doh!@foo', bar: 'baz#1'}); })); - it('should not encode @ in url params', inject(function($browser) { + it('should not encode @ in url params', inject(function($httpBackend) { //encodeURIComponent is too agressive and doesn't follow http://www.ietf.org/rfc/rfc3986.txt //with regards to the character set (pchar) allowed in path segments //so we need this test to make sure that we don't over-encode the params and break stuff like //buzz api which uses @self var R = resource.route('/Path/:a'); - $browser.xhr.expectGET('/Path/doh@fo%20o?!do%26h=g%3Da+h&:bar=$baz@1').respond({}); + $httpBackend.expect('GET', '/Path/doh@fo%20o?!do%26h=g%3Da+h&:bar=$baz@1').respond('{}'); R.get({a: 'doh@fo o', ':bar': '$baz@1', '!do&h': 'g=a h'}); })); - it('should encode & in url params', inject(function($browser) { + it('should encode & in url params', inject(function($httpBackend) { var R = resource.route('/Path/:a'); - $browser.xhr.expectGET('/Path/doh&foo?bar=baz%261').respond({}); + $httpBackend.expect('GET', '/Path/doh&foo?bar=baz%261').respond('{}'); R.get({a: 'doh&foo', bar: 'baz&1'}); })); - it("should build resource with default param", inject(function($browser) { - $browser.xhr.expectGET('/Order/123/Line/456.visa?minimum=0.05').respond({id:'abc'}); - var LineItem = resource.route('/Order/:orderId/Line/:id:verb', {orderId: '123', id: '@id.key', verb:'.visa', minimum:0.05}); - var item = LineItem.get({id:456}); - $browser.xhr.flush(); + it('should build resource with default param', inject(function($httpBackend) { + $httpBackend.expect('GET', '/Order/123/Line/456.visa?minimum=0.05').respond({id: 'abc'}); + var LineItem = resource.route('/Order/:orderId/Line/:id:verb', + {orderId: '123', id: '@id.key', verb:'.visa', minimum: 0.05}); + var item = LineItem.get({id: 456}); + $httpBackend.flush(); nakedExpect(item).toEqual({id:'abc'}); })); - it("should build resource with action default param overriding default param", inject(function($browser) { - $browser.xhr.expectGET('/Customer/123').respond({id:'abc'}); + it("should build resource with action default param overriding default param", inject(function($httpBackend) { + $httpBackend.expect('GET', '/Customer/123').respond({id: 'abc'}); var TypeItem = resource.route('/:type/:typeId', {type: 'Order'}, {get: {method: 'GET', params: {type: 'Customer'}}}); - var item = TypeItem.get({typeId:123}); - $browser.xhr.flush(); - nakedExpect(item).toEqual({id:'abc'}); + var item = TypeItem.get({typeId: 123}); + + $httpBackend.flush(); + nakedExpect(item).toEqual({id: 'abc'}); })); - it("should create resource", inject(function($browser) { - $browser.xhr.expectPOST('/CreditCard', {name:'misko'}).respond({id:123, name:'misko'}); + it("should create resource", inject(function($httpBackend) { + $httpBackend.expect('POST', '/CreditCard', '{"name":"misko"}').respond({id: 123, name: 'misko'}); - var cc = CreditCard.save({name:'misko'}, callback); - nakedExpect(cc).toEqual({name:'misko'}); + var cc = CreditCard.save({name: 'misko'}, callback); + nakedExpect(cc).toEqual({name: 'misko'}); expect(callback).not.toHaveBeenCalled(); - $browser.xhr.flush(); - nakedExpect(cc).toEqual({id:123, name:'misko'}); + + $httpBackend.flush(); + nakedExpect(cc).toEqual({id: 123, name: 'misko'}); expect(callback).toHaveBeenCalledWith(cc); })); - it("should read resource", inject(function($browser) { - $browser.xhr.expectGET("/CreditCard/123").respond({id:123, number:'9876'}); - var cc = CreditCard.get({id:123}, callback); + it("should read resource", inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard/123').respond({id: 123, number: '9876'}); + var cc = CreditCard.get({id: 123}, callback); + expect(cc instanceof CreditCard).toBeTruthy(); nakedExpect(cc).toEqual({}); expect(callback).not.toHaveBeenCalled(); - $browser.xhr.flush(); - nakedExpect(cc).toEqual({id:123, number:'9876'}); + + $httpBackend.flush(); + nakedExpect(cc).toEqual({id: 123, number: '9876'}); expect(callback).toHaveBeenCalledWith(cc); })); - it("should read partial resource", inject(function($browser) { - $browser.xhr.expectGET("/CreditCard").respond([{id:{key:123}}]); - $browser.xhr.expectGET("/CreditCard/123").respond({id:{key:123}, number:'9876'}); + it("should read partial resource", inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard').respond([{id:{key:123}}]); + $httpBackend.expect('GET', '/CreditCard/123').respond({id: {key: 123}, number: '9876'}); + var ccs = CreditCard.query(); - $browser.xhr.flush(); + + $httpBackend.flush(); expect(ccs.length).toEqual(1); + var cc = ccs[0]; - expect(cc instanceof CreditCard).toBeTruthy(); - expect(cc.number).not.toBeDefined(); + expect(cc instanceof CreditCard).toBe(true); + expect(cc.number).toBeUndefined(); + cc.$get(callback); - $browser.xhr.flush(); + $httpBackend.flush(); expect(callback).toHaveBeenCalledWith(cc); expect(cc.number).toEqual('9876'); })); - it("should update resource", inject(function($browser) { - $browser.xhr.expectPOST('/CreditCard/123', {id:{key:123}, name:'misko'}).respond({id:{key:123}, name:'rama'}); + it("should update resource", inject(function($httpBackend) { + $httpBackend.expect('POST', '/CreditCard/123', '{"id":{"key":123},"name":"misko"}'). + respond({id: {key: 123}, name: 'rama'}); - var cc = CreditCard.save({id:{key:123}, name:'misko'}, callback); + var cc = CreditCard.save({id: {key: 123}, name: 'misko'}, callback); nakedExpect(cc).toEqual({id:{key:123}, name:'misko'}); expect(callback).not.toHaveBeenCalled(); - $browser.xhr.flush(); + $httpBackend.flush(); })); - it("should query resource", inject(function($browser) { - $browser.xhr.expectGET("/CreditCard?key=value").respond([{id:1}, {id:2}]); + it("should query resource", inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard?key=value').respond([{id: 1}, {id: 2}]); - var ccs = CreditCard.query({key:'value'}, callback); + var ccs = CreditCard.query({key: 'value'}, callback); expect(ccs).toEqual([]); expect(callback).not.toHaveBeenCalled(); - $browser.xhr.flush(); + + $httpBackend.flush(); nakedExpect(ccs).toEqual([{id:1}, {id:2}]); expect(callback).toHaveBeenCalledWith(ccs); })); - it("should have all arguments optional", inject(function($browser) { - $browser.xhr.expectGET('/CreditCard').respond([{id:1}]); + it("should have all arguments optional", inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard').respond([{id:1}]); + var log = ''; var ccs = CreditCard.query(function() { log += 'cb;'; }); - $browser.xhr.flush(); + + $httpBackend.flush(); nakedExpect(ccs).toEqual([{id:1}]); expect(log).toEqual('cb;'); })); - it('should delete resource and call callback', inject(function($browser) { - $browser.xhr.expectDELETE("/CreditCard/123").respond(200, {}); + it('should delete resource and call callback', inject(function($httpBackend) { + $httpBackend.expect('DELETE', '/CreditCard/123').respond({}); + $httpBackend.expect('DELETE', '/CreditCard/333').respond(204, null); CreditCard.remove({id:123}, callback); expect(callback).not.toHaveBeenCalled(); - $browser.xhr.flush(); + + $httpBackend.flush(); nakedExpect(callback.mostRecentCall.args).toEqual([{}]); callback.reset(); - $browser.xhr.expectDELETE("/CreditCard/333").respond(204, null); CreditCard.remove({id:333}, callback); expect(callback).not.toHaveBeenCalled(); - $browser.xhr.flush(); + + $httpBackend.flush(); nakedExpect(callback.mostRecentCall.args).toEqual([{}]); })); - it('should post charge verb', inject(function($browser) { - $browser.xhr.expectPOST('/CreditCard/123!charge?amount=10', {auth:'abc'}).respond({success:'ok'}); - - CreditCard.charge({id:123, amount:10},{auth:'abc'}, callback); + it('should post charge verb', inject(function($httpBackend) { + $httpBackend.expect('POST', '/CreditCard/123!charge?amount=10', '{"auth":"abc"}').respond({success: 'ok'}); + CreditCard.charge({id:123, amount:10}, {auth:'abc'}, callback); })); - it('should post charge verb on instance', inject(function($browser) { - $browser.xhr.expectPOST('/CreditCard/123!charge?amount=10', {id:{key:123}, name:'misko'}).respond({success:'ok'}); + it('should post charge verb on instance', inject(function($httpBackend) { + $httpBackend.expect('POST', '/CreditCard/123!charge?amount=10', + '{"id":{"key":123},"name":"misko"}').respond({success: 'ok'}); var card = new CreditCard({id:{key:123}, name:'misko'}); card.$charge({amount:10}, callback); })); - it('should create on save', inject(function($browser) { - $browser.xhr.expectPOST('/CreditCard', {name:'misko'}).respond({id:123}); + it('should create on save', inject(function($httpBackend) { + $httpBackend.expect('POST', '/CreditCard', '{"name":"misko"}').respond({id: 123}); + var cc = new CreditCard(); expect(cc.$get).toBeDefined(); expect(cc.$query).toBeDefined(); @@ -198,97 +218,72 @@ xdescribe("resource", function() { cc.name = 'misko'; cc.$save(callback); nakedExpect(cc).toEqual({name:'misko'}); - $browser.xhr.flush(); + + $httpBackend.flush(); nakedExpect(cc).toEqual({id:123}); expect(callback).toHaveBeenCalledWith(cc); })); - it('should not mutate the resource object if response contains no body', inject(function($browser) { + it('should not mutate the resource object if response contains no body', inject(function($httpBackend) { var data = {id:{key:123}, number:'9876'}; - $browser.xhr.expectGET("/CreditCard/123").respond(data); + $httpBackend.expect('GET', '/CreditCard/123').respond(data); + $httpBackend.expect('POST', '/CreditCard/123', toJson(data)).respond(''); + var cc = CreditCard.get({id:123}); - $browser.xhr.flush(); - expect(cc instanceof CreditCard).toBeTruthy(); - var idBefore = cc.id; + $httpBackend.flush(); + expect(cc instanceof CreditCard).toBe(true); - $browser.xhr.expectPOST("/CreditCard/123", data).respond(''); + var idBefore = cc.id; cc.$save(); - $browser.xhr.flush(); + $httpBackend.flush(); expect(idBefore).toEqual(cc.id); })); - it('should bind default parameters', inject(function($browser) { - $browser.xhr.expectGET('/CreditCard/123.visa?minimum=0.05').respond({id:123}); + it('should bind default parameters', inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard/123.visa?minimum=0.05').respond({id: 123}); var Visa = CreditCard.bind({verb:'.visa', minimum:0.05}); var visa = Visa.get({id:123}); - $browser.xhr.flush(); + $httpBackend.flush(); nakedExpect(visa).toEqual({id:123}); })); - it('should excersize full stack', inject(function($rootScope, $browser, $resource, $compile) { - $compile('
    ')($rootScope); + it('should excersize full stack', inject(function($httpBackend, $resource) { var Person = $resource('/Person/:id'); - $browser.xhr.expectGET('/Person/123').respond('\n{\n"name":\n"misko"\n}\n'); + + $httpBackend.expect('GET', '/Person/123').respond('\n{\n"name":\n"misko"\n}\n'); var person = Person.get({id:123}); - $browser.xhr.flush(); + $httpBackend.flush(); expect(person.name).toEqual('misko'); })); - it('should return the same object when verifying the cache', - inject(function($rootScope, $compile, $browser, $resource) { - $compile('
    ')($rootScope); - var Person = $resource('/Person/:id', null, {query: {method:'GET', isArray: true, verifyCache: true}}); - $browser.xhr.expectGET('/Person/123').respond('[\n{\n"name":\n"misko"\n}\n]'); - var person = Person.query({id:123}); - $browser.xhr.flush(); - expect(person[0].name).toEqual('misko'); - - $browser.xhr.expectGET('/Person/123').respond('[\n{\n"name":\n"rob"\n}\n]'); - var person2 = Person.query({id:123}); - $browser.defer.flush(); - - expect(person2[0].name).toEqual('misko'); - var person2Cache = person2; - $browser.xhr.flush(); - expect(person2Cache).toEqual(person2); - expect(person2[0].name).toEqual('rob'); - })); - describe('failure mode', function() { var ERROR_CODE = 500, ERROR_RESPONSE = 'Server Error', errorCB; beforeEach(function() { - errorCB = jasmine.createSpy(); + errorCB = jasmine.createSpy('error').andCallFake(function(response, status) { + expect(response).toBe(ERROR_RESPONSE); + expect(status).toBe(ERROR_CODE); + }); }); - it('should report error when non 2xx if error callback is not provided', - inject(function($browser, $xhrError) { - $browser.xhr.expectGET('/CreditCard/123').respond(ERROR_CODE, ERROR_RESPONSE); - CreditCard.get({id:123}); - $browser.xhr.flush(); - expect($xhrError).toHaveBeenCalled(); - })); + it('should call the error callback if provided on non 2xx response', inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard/123').respond(ERROR_CODE, ERROR_RESPONSE); - it('should call the error callback if provided on non 2xx response', - inject(function($browser, $xhrError) { - $browser.xhr.expectGET('/CreditCard/123').respond(ERROR_CODE, ERROR_RESPONSE); CreditCard.get({id:123}, callback, errorCB); - $browser.xhr.flush(); - expect(errorCB).toHaveBeenCalledWith(500, ERROR_RESPONSE); + $httpBackend.flush(); + expect(errorCB).toHaveBeenCalledOnce(); expect(callback).not.toHaveBeenCalled(); - expect($xhrError).not.toHaveBeenCalled(); })); - it('should call the error callback if provided on non 2xx response', - inject(function($browser, $xhrError) { - $browser.xhr.expectGET('/CreditCard').respond(ERROR_CODE, ERROR_RESPONSE); + it('should call the error callback if provided on non 2xx response', inject(function($httpBackend) { + $httpBackend.expect('GET', '/CreditCard').respond(ERROR_CODE, ERROR_RESPONSE); + CreditCard.get(callback, errorCB); - $browser.xhr.flush(); - expect(errorCB).toHaveBeenCalledWith(500, ERROR_RESPONSE); + $httpBackend.flush(); + expect(errorCB).toHaveBeenCalledOnce(); expect(callback).not.toHaveBeenCalled(); - expect($xhrError).not.toHaveBeenCalled(); })); }); }); From a315a108e848c79778cb94f1e743e29b6811ca27 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 26 Oct 2011 21:16:01 -0700 Subject: [PATCH 15/28] refactor($http): change callback matching mechanism --- src/service/http.js | 59 ++++++++++++++-------------------------- test/service/httpSpec.js | 13 +++------ 2 files changed, 25 insertions(+), 47 deletions(-) diff --git a/src/service/http.js b/src/service/http.js index 3b207a13defc..daf1213812d5 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -268,55 +268,33 @@ function $HttpProvider() { * - clear parsed headers */ function fireCallbacks(response, status) { + var strStatus = status + ''; + // transform the response response = transform(response, cfg.transformResponse || $config.transformResponse, rawRequest); - var regexp = statusToRegexp(status), - pattern, callback, idx; - - // remove from pending requests + var idx; // remove from pending requests if ((idx = indexOf($http.pendingRequests, self)) !== -1) $http.pendingRequests.splice(idx, 1); // normalize internal statuses to 0 status = Math.max(status, 0); - for (var i = 0; i < callbacks.length; i += 2) { - pattern = callbacks[i]; - callback = callbacks[i + 1]; - if (regexp.test(pattern)) { + forEach(callbacks, function(callback) { + if (callback.regexp.test(strStatus)) { try { - callback(response, status, headers); + // use local var to call it without context + var fn = callback.fn; + fn(response, status, headers); } catch(e) { $exceptionHandler(e); } } - } + }); $rootScope.$apply(); parsedHeaders = null; } - /** - * Convert given status code number into regexp - * - * It would be much easier to convert registered statuses (e.g. "2xx") into regexps, - * but this has an advantage of creating just one regexp, instead of one regexp per - * registered callback. Anyway, probably not big deal. - * - * @param status - * @returns {RegExp} - */ - function statusToRegexp(status) { - var strStatus = status + '', - regexp = ''; - - for (var i = Math.min(0, strStatus.length - 3); i < strStatus.length; i++) { - regexp += '(' + (strStatus.charAt(i) || 0) + '|x)'; - } - - return new RegExp(regexp); - } - /** * This is the third argument in any user callback * @see parseHeaders @@ -392,11 +370,12 @@ function $HttpProvider() { * .on('2xx', function(){}); * .on('2x1', function(){}); * .on('404', function(){}); - * .on('xxx', function(){}); * .on('20x,3xx', function(){}); * .on('success', function(){}); * .on('error', function(){}); * .on('always', function(){}); + * .on('timeout', function(){}); + * .on('abort', function(){}); * * @param {string} pattern Status code pattern with "x" for any number * @param {function(*, number, Object)} callback Function to be called when response arrives @@ -405,14 +384,18 @@ function $HttpProvider() { this.on = function(pattern, callback) { var alias = { success: '2xx', - error: '0-2,0-1,000,4xx,5xx', - always: 'xxx', - timeout: '0-1', - abort: '000' + error: '-2,-1,0,4xx,5xx', + always: 'xxx,xx,x', + timeout: '-1', + abort: '0' }; - callbacks.push(alias[pattern] || pattern); - callbacks.push(callback); + callbacks.push({ + fn: callback, + // create regexp from given pattern + regexp: new RegExp('^(' + (alias[pattern] || pattern).replace(/,/g, '|'). + replace(/x/g, '.') + ')$') + }); return this; }; diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index b39ac3d7438c..11165adc2c4a 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -486,7 +486,7 @@ describe('$http', function() { $httpBackend.flush(); if (match) expect(callback).toHaveBeenCalledOnce(); - else expect(callback).not.toHaveBeenCalledOnce(); + else expect(callback).not.toHaveBeenCalled(); } beforeEach(function() { @@ -620,11 +620,6 @@ describe('$http', function() { }); - it('should call "xxx" when 0 status code', function() { - expectToMatch(0, 'xxx'); - }); - - it('should not call "2xx" when 0 status code', function() { expectToNotMatch(0, '2xx'); }); @@ -634,9 +629,9 @@ describe('$http', function() { expect(status).toBe(0); }); - $http({method: 'GET', url: '/0'}).on('xxx', callback); - $http({method: 'GET', url: '/-1'}).on('xxx', callback); - $http({method: 'GET', url: '/-2'}).on('xxx', callback); + $http({method: 'GET', url: '/0'}).on('always', callback); + $http({method: 'GET', url: '/-1'}).on('always', callback); + $http({method: 'GET', url: '/-2'}).on('always', callback); $httpBackend.flush(); expect(callback).toHaveBeenCalled(); From 85dba30a46e1c1acfd310e16a1f7c8af5507cac9 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Mon, 31 Oct 2011 11:34:28 -0700 Subject: [PATCH 16/28] fix($http): add .send() alias for .retry() to get better stack trace on error --- src/service/http.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/service/http.js b/src/service/http.js index daf1213812d5..92b95f35cb82 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -96,7 +96,7 @@ function $HttpProvider() { // the actual service function $http(config) { - return new XhrFuture().retry(config); + return new XhrFuture().send(config); } $http.pendingRequests = []; @@ -347,6 +347,9 @@ function $HttpProvider() { return this; }; + // just alias so that in stack trace we can see send() instead of retry() + this.send = this.retry; + /** * Abort the request */ From ceae30a3898cde4bcb7477e181afc15fc11b747b Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Mon, 31 Oct 2011 11:36:31 -0700 Subject: [PATCH 17/28] feat(mock.$httpBackend): throw when nothing to flush, dump data/headers when expected different --- src/angular-mocks.js | 42 ++++++++++++++++++++------------------- test/angular-mocksSpec.js | 31 +++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 00541c8f0704..e2348f6be5eb 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -591,12 +591,20 @@ angular.module.ngMock.$HttpBackendProvider = function() { expectation = expectations[0], wasExpected = false; + function prettyPrint(data) { + if (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp) + return data; + return angular.toJson(data); + } + if (expectation && expectation.match(method, url)) { if (!expectation.matchData(data)) - throw Error('Expected ' + method + ' ' + url + ' with different data'); + throw Error('Expected ' + expectation + ' with different data\n' + + 'EXPECTED: ' + prettyPrint(expectation.data) + '\nGOT: ' + data); if (!expectation.matchHeaders(headers)) - throw Error('Expected ' + method + ' ' + url + ' with different headers'); + throw Error('Expected ' + expectation + ' with different headers\n' + + 'EXPECTED: ' + prettyPrint(expectation.headers) + '\nGOT: ' + prettyPrint(headers)); expectations.shift(); @@ -648,15 +656,14 @@ angular.module.ngMock.$HttpBackendProvider = function() { }; $httpBackend.flush = function(count) { + if (!responses.length) throw Error('No pending request to flush !'); count = count || responses.length; while (count--) { - if (!responses.length) throw Error('No more pending requests'); + if (!responses.length) throw Error('No more pending request to flush !'); responses.shift()(); } }; - - $httpBackend.verifyExpectations = function() { if (expectations.length) { throw Error('Unsatisfied requests: ' + expectations.join(', ')); @@ -674,6 +681,9 @@ angular.module.ngMock.$HttpBackendProvider = function() { function MockHttpExpectation(method, url, data, headers) { + this.data = data; + this.headers = headers; + this.match = function(m, u, d, h) { if (method != m) return false; if (!this.matchUrl(u)) return false; @@ -684,29 +694,21 @@ function MockHttpExpectation(method, url, data, headers) { this.matchUrl = function(u) { if (!url) return true; - if (angular.isFunction(url.test)) { - if (!url.test(u)) return false; - } else if (url != u) return false; - - return true; + if (angular.isFunction(url.test)) return url.test(u); + return url == u; }; this.matchHeaders = function(h) { if (angular.isUndefined(headers)) return true; - if (angular.isFunction(headers)) { - if (!headers(h)) return false; - } else if (!angular.equals(headers, h)) return false; - - return true; + if (angular.isFunction(headers)) return headers(h); + return angular.equals(headers, h); }; this.matchData = function(d) { if (angular.isUndefined(data)) return true; - if (data && angular.isFunction(data.test)) { - if (!data.test(d)) return false; - } else if (data != d) return false; - - return true; + if (data && angular.isFunction(data.test)) return data.test(d); + if (data && !angular.isString(data)) return angular.toJson(data) == d; + return data == d; }; this.toString = function() { diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index 183c7d7442b7..3ab8e757bff1 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -531,7 +531,8 @@ describe('mocks', function() { expect(function() { hb('GET', '/match', null, noop, {}); - }).toThrow('Expected GET /match with different headers'); + }).toThrow('Expected GET /match with different headers\n' + + 'EXPECTED: {"Content-Type":"application/json"}\nGOT: {}'); }); @@ -541,7 +542,8 @@ describe('mocks', function() { expect(function() { hb('GET', '/match', 'different', noop, {}); - }).toThrow('Expected GET /match with different data'); + }).toThrow('Expected GET /match with different data\n' + + 'EXPECTED: some-data\nGOT: different'); }); @@ -589,11 +591,22 @@ describe('mocks', function() { hb.when('GET').then(200, ''); hb('GET', '/url', null, callback); - expect(function() {hb.flush(2);}).toThrow('No more pending requests'); + expect(function() {hb.flush(2);}).toThrow('No more pending request to flush !'); expect(callback).toHaveBeenCalledOnce(); }); + it('(flush) should throw exception when no request to flush', function() { + expect(function() {hb.flush();}).toThrow('No pending request to flush !'); + + hb.when('GET').then(200, ''); + hb('GET', '/some', null, callback); + hb.flush(); + + expect(function() {hb.flush();}).toThrow('No pending request to flush !'); + }); + + it('respond() should set default status 200 if not defined', function() { callback.andCallFake(function(status, response) { expect(status).toBe(200); @@ -713,12 +726,18 @@ describe('mocks', function() { it('should remove all responses', function() { - hb.expect('GET', '/url').respond(200, '', {}); - hb('GET', '/url', null, callback, {}); + var cancelledClb = jasmine.createSpy('cancelled'); + + hb.expect('GET', '/url').respond(200, ''); + hb('GET', '/url', null, cancelledClb); hb.resetExpectations(); + + hb.expect('GET', '/url').respond(300, ''); + hb('GET', '/url', null, callback, {}); hb.flush(); - expect(callback).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledOnce(); + expect(cancelledClb).not.toHaveBeenCalled(); }); }); From ba7bae401340f3e4af0aa75793d96eb7df8c205a Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Mon, 31 Oct 2011 12:03:09 -0700 Subject: [PATCH 18/28] feat($http): broadcast $http.request event --- src/service/http.js | 1 + test/service/httpSpec.js | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/src/service/http.js b/src/service/http.js index 92b95f35cb82..23f33fadd71a 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -343,6 +343,7 @@ function $HttpProvider() { rawRequest = $httpBackend(cfg.method, cfg.url, data, done, headers, cfg.timeout); } + $rootScope.$broadcast('$http.request', self); $http.pendingRequests.push(self); return this; }; diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 11165adc2c4a..12921992544e 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -686,6 +686,16 @@ describe('$http', function() { }); + it('should broadcast $http.request', function() { + $httpBackend.when('GET').then(200); + scope.$on('$http.request', callback); + var xhrFuture = $http({method: 'GET', url: '/whatever'}); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[1]).toBe(xhrFuture); + }); + + describe('transform', function() { describe('request', function() { From f646f018364c14eccb4615d4e65ad53d4baa04be Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Mon, 31 Oct 2011 20:34:03 -0700 Subject: [PATCH 19/28] feat(mock.$httpBackend): add verifyNoOutstandingRequest method + rename verifyExpectations to verifyNoOutstandingExpectation --- src/angular-mocks.js | 8 +++++++- test/ResourceSpec.js | 2 +- test/angular-mocksSpec.js | 26 +++++++++++++++++++------- test/service/httpSpec.js | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index e2348f6be5eb..4cf4236c347b 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -664,12 +664,18 @@ angular.module.ngMock.$HttpBackendProvider = function() { } }; - $httpBackend.verifyExpectations = function() { + $httpBackend.verifyNoOutstandingExpectation = function() { if (expectations.length) { throw Error('Unsatisfied requests: ' + expectations.join(', ')); } }; + $httpBackend.verifyNoOutstandingRequest = function() { + if (responses.length) { + throw Error('Unflushed requests: ' + responses.length); + } + }; + $httpBackend.resetExpectations = function() { expectations = []; responses = []; diff --git a/test/ResourceSpec.js b/test/ResourceSpec.js index 5d91bf3e1857..eb458c2d326d 100644 --- a/test/ResourceSpec.js +++ b/test/ResourceSpec.js @@ -20,7 +20,7 @@ describe("resource", function() { ); afterEach(inject(function($httpBackend) { - $httpBackend.verifyExpectations(); + $httpBackend.verifyNoOutstandingExpectation(); })); it("should build resource", function() { diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index 3ab8e757bff1..cb11d355342d 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -559,7 +559,7 @@ describe('mocks', function() { hb.flush(); expect(callback).toHaveBeenCalled(); - expect(function() { hb.verifyExpectations(); }).not.toThrow(); + expect(function() { hb.verifyNoOutstandingExpectation(); }).not.toThrow(); }); @@ -651,7 +651,7 @@ describe('mocks', function() { hb('GET', '/some-url', null, callback); hb.flush(); expect(callback).toHaveBeenCalledOnce(); - hb.verifyExpectations(); + hb.verifyNoOutstandingExpectation(); }); @@ -680,7 +680,7 @@ describe('mocks', function() { }); - describe('verify', function() { + describe('verifyExpectations', function() { it('should throw exception if not all expectations were satisfied', function() { hb.expect('POST', '/u1', 'ddd').respond(201, '', {}); @@ -689,7 +689,7 @@ describe('mocks', function() { hb('POST', '/u1', 'ddd', noop, {}); - expect(function() {hb.verifyExpectations();}) + expect(function() {hb.verifyNoOutstandingExpectation();}) .toThrow('Unsatisfied requests: GET /u2, POST /u3'); }); @@ -697,7 +697,7 @@ describe('mocks', function() { it('should do nothing when no expectation', function() { hb.when('DELETE', '/some').then(200, ''); - expect(function() {hb.verifyExpectations();}).not.toThrow(); + expect(function() {hb.verifyNoOutstandingExpectation();}).not.toThrow(); }); @@ -709,7 +709,19 @@ describe('mocks', function() { hb('GET', '/u2', noop); hb('POST', '/u3', noop); - expect(function() {hb.verifyExpectations();}).not.toThrow(); + expect(function() {hb.verifyNoOutstandingExpectation();}).not.toThrow(); + }); + }); + + describe('verifyRequests', function() { + + it('should throw exception if not all requests were flushed', function() { + hb.when('GET').then(200); + hb('GET', '/some', null, noop, {}); + + expect(function() { + hb.verifyNoOutstandingRequest(); + }).toThrow('Unflushed requests: 1'); }); }); @@ -721,7 +733,7 @@ describe('mocks', function() { hb.expect('POST', '/u3').respond(201, '', {}); hb.resetExpectations(); - expect(function() {hb.verifyExpectations();}).not.toThrow(); + expect(function() {hb.verifyNoOutstandingExpectation();}).not.toThrow(); }); diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 12921992544e..72fd5f8e5468 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -19,7 +19,7 @@ describe('$http', function() { afterEach(function() { if ($exceptionHandler.errors.length) throw $exceptionHandler.errors; - $httpBackend.verifyExpectations(); + $httpBackend.verifyNoOutstandingExpectation(); }); From 77d4c38a201d3ae69c3580df77ed5b3b10363940 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 1 Nov 2011 13:21:00 -0700 Subject: [PATCH 20/28] fix(mock.$httpBackend): flush() even requests sent during callbacks --- src/angular-mocks.js | 13 +++++++++---- test/angular-mocksSpec.js | 5 ++--- test/widgetsSpec.js | 2 -- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 4cf4236c347b..17d317b7d291 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -657,10 +657,15 @@ angular.module.ngMock.$HttpBackendProvider = function() { $httpBackend.flush = function(count) { if (!responses.length) throw Error('No pending request to flush !'); - count = count || responses.length; - while (count--) { - if (!responses.length) throw Error('No more pending request to flush !'); - responses.shift()(); + + if (angular.isDefined(count)) { + while (count--) { + if (!responses.length) throw Error('No more pending request to flush !'); + responses.shift()(); + } + } else { + while (responses.length) + responses.shift()(); } }; diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index cb11d355342d..5b24c440ee6d 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -563,15 +563,14 @@ describe('mocks', function() { }); - it('flush() should not flush requests fired during callbacks', function() { - // regression + it('flush() should flush requests fired during callbacks', function() { hb.when('GET').then(200, ''); hb('GET', '/some', null, function() { hb('GET', '/other', null, callback); }); hb.flush(); - expect(callback).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalled(); }); diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index c3bc13335c4b..36e9494720c3 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -567,8 +567,6 @@ describe("widget", function() { $httpBackend.expect('GET', 'viewPartial.html').respond('content'); $httpBackend.flush(); - myApp.$digest(); - $httpBackend.flush(); expect(myApp.$element.text()).toEqual('include: view: content'); expect($route.current.template).toEqual('viewPartial.html'); From a7f13bf3743d8adc5f4285d87a2c1d8802edeb21 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 1 Nov 2011 13:27:42 -0700 Subject: [PATCH 21/28] refactor(mock.$httpBackend): rename when().then() to when().respond() --- src/angular-mocks.js | 2 +- test/ResourceSpec.js | 2 +- test/angular-mocksSpec.js | 62 +++++++++++++++++++-------------------- test/service/httpSpec.js | 20 ++++++------- 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 17d317b7d291..d9535f6494ce 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -639,7 +639,7 @@ angular.module.ngMock.$HttpBackendProvider = function() { var definition = new MockHttpExpectation(method, url, data, headers); definitions.push(definition); return { - then: function(status, data, headers) { + respond: function(status, data, headers) { definition.response = angular.isFunction(status) ? status : createResponse(status, data, headers); } }; diff --git a/test/ResourceSpec.js b/test/ResourceSpec.js index eb458c2d326d..fd7e41dbcb2d 100644 --- a/test/ResourceSpec.js +++ b/test/ResourceSpec.js @@ -40,7 +40,7 @@ describe("resource", function() { it('should ignore slashes of undefinend parameters', inject(function($httpBackend) { var R = resource.route('/Path/:a/:b/:c'); - $httpBackend.when('GET').then('{}'); + $httpBackend.when('GET').respond('{}'); $httpBackend.expect('GET', '/Path'); $httpBackend.expect('GET', '/Path/1'); $httpBackend.expect('GET', '/Path/2/3'); diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index 5b24c440ee6d..01c1b6ca4db3 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -380,8 +380,8 @@ describe('mocks', function() { it('should respond with first matched definition', function() { - hb.when('GET', '/url1').then(200, 'content', {}); - hb.when('GET', '/url1').then(201, 'another', {}); + hb.when('GET', '/url1').respond(200, 'content', {}); + hb.when('GET', '/url1').respond(201, 'another', {}); callback.andCallFake(function(status, response) { expect(status).toBe(200); @@ -396,7 +396,7 @@ describe('mocks', function() { it('should throw error when unexpected request', function() { - hb.when('GET', '/url1').then(200, 'content'); + hb.when('GET', '/url1').respond(200, 'content'); expect(function() { hb('GET', '/xxx'); }).toThrow('Unexpected request: GET /xxx'); @@ -404,9 +404,9 @@ describe('mocks', function() { it('should match headers if specified', function() { - hb.when('GET', '/url', null, {'X': 'val1'}).then(201, 'content1'); - hb.when('GET', '/url', null, {'X': 'val2'}).then(202, 'content2'); - hb.when('GET', '/url').then(203, 'content3'); + hb.when('GET', '/url', null, {'X': 'val1'}).respond(201, 'content1'); + hb.when('GET', '/url', null, {'X': 'val2'}).respond(202, 'content2'); + hb.when('GET', '/url').respond(203, 'content3'); hb('GET', '/url', null, function(status, response) { expect(status).toBe(203); @@ -428,8 +428,8 @@ describe('mocks', function() { it('should match data if specified', function() { - hb.when('GET', '/a/b', '{a: true}').then(201, 'content1'); - hb.when('GET', '/a/b').then(202, 'content2'); + hb.when('GET', '/a/b', '{a: true}').respond(201, 'content1'); + hb.when('GET', '/a/b').respond(202, 'content2'); hb('GET', '/a/b', '{a: true}', function(status, response) { expect(status).toBe(201); @@ -446,7 +446,7 @@ describe('mocks', function() { it('should match only method', function() { - hb.when('GET').then(202, 'c'); + hb.when('GET').respond(202, 'c'); callback.andCallFake(function(status, response) { expect(status).toBe(202); expect(response).toBe('c'); @@ -462,7 +462,7 @@ describe('mocks', function() { it('should expose given headers', function() { - hb.when('GET', '/u1').then(200, null, {'X-Fake': 'Header', 'Content-Type': 'application/json'}); + hb.when('GET', '/u1').respond(200, null, {'X-Fake': 'Header', 'Content-Type': 'application/json'}); var xhr = hb('GET', '/u1', null, noop, {}); hb.flush(); expect(xhr.getResponseHeader('X-Fake')).toBe('Header'); @@ -471,8 +471,8 @@ describe('mocks', function() { it('should preserve the order of requests', function() { - hb.when('GET', '/url1').then(200, 'first'); - hb.when('GET', '/url2').then(201, 'second'); + hb.when('GET', '/url1').respond(200, 'first'); + hb.when('GET', '/url2').respond(201, 'second'); hb('GET', '/url2', null, callback); hb('GET', '/url1', null, callback); @@ -485,8 +485,8 @@ describe('mocks', function() { }); - it('then() should take function', function() { - hb.when('GET', '/some').then(function(m, u, d, h) { + it('respond() should take function', function() { + hb.when('GET', '/some').respond(function(m, u, d, h) { return [301, m + u + ';' + d + ';a=' + h.a, {'Connection': 'keep-alive'}]; }); @@ -516,7 +516,7 @@ describe('mocks', function() { expect(response).toBe('expect'); }); - hb.when('GET', '/url').then(200, 'when'); + hb.when('GET', '/url').respond(200, 'when'); hb.expect('GET', '/url').respond(300, 'expect'); hb('GET', '/url', null, callback, {}); @@ -526,7 +526,7 @@ describe('mocks', function() { it ('should throw exception when only headers differes from expectation', function() { - hb.when('GET').then(200, '', {}); + hb.when('GET').respond(200, '', {}); hb.expect('GET', '/match', undefined, {'Content-Type': 'application/json'}); expect(function() { @@ -537,7 +537,7 @@ describe('mocks', function() { it ('should throw exception when only data differes from expectation', function() { - hb.when('GET').then(200, '', {}); + hb.when('GET').respond(200, '', {}); hb.expect('GET', '/match', 'some-data'); expect(function() { @@ -547,13 +547,13 @@ describe('mocks', function() { }); - it('expect() should without respond() and use then()', function() { + it('expect() should without respond() and use respond()', function() { callback.andCallFake(function(status, response) { expect(status).toBe(201); expect(response).toBe('data'); }); - hb.when('GET', '/some').then(201, 'data'); + hb.when('GET', '/some').respond(201, 'data'); hb.expect('GET', '/some'); hb('GET', '/some', null, callback); hb.flush(); @@ -564,7 +564,7 @@ describe('mocks', function() { it('flush() should flush requests fired during callbacks', function() { - hb.when('GET').then(200, ''); + hb.when('GET').respond(200, ''); hb('GET', '/some', null, function() { hb('GET', '/other', null, callback); }); @@ -575,7 +575,7 @@ describe('mocks', function() { it('flush() should flush given number of pending requests', function() { - hb.when('GET').then(200, ''); + hb.when('GET').respond(200, ''); hb('GET', '/some', null, callback); hb('GET', '/some', null, callback); hb('GET', '/some', null, callback); @@ -587,7 +587,7 @@ describe('mocks', function() { it('flush() should throw exception when flushing more requests than pending', function() { - hb.when('GET').then(200, ''); + hb.when('GET').respond(200, ''); hb('GET', '/url', null, callback); expect(function() {hb.flush(2);}).toThrow('No more pending request to flush !'); @@ -598,7 +598,7 @@ describe('mocks', function() { it('(flush) should throw exception when no request to flush', function() { expect(function() {hb.flush();}).toThrow('No pending request to flush !'); - hb.when('GET').then(200, ''); + hb.when('GET').respond(200, ''); hb('GET', '/some', null, callback); hb.flush(); @@ -622,14 +622,14 @@ describe('mocks', function() { }); - it('then() should set default status 200 if not defined', function() { + it('respond() should set default status 200 if not defined', function() { callback.andCallFake(function(status, response) { expect(status).toBe(200); expect(response).toBe('some-data'); }); - hb.when('GET', '/url1').then('some-data'); - hb.when('GET', '/url2').then('some-data', {'X-Header': 'true'}); + hb.when('GET', '/url1').respond('some-data'); + hb.when('GET', '/url2').respond('some-data', {'X-Header': 'true'}); hb('GET', '/url1', null, callback); hb('GET', '/url2', null, callback); hb.flush(); @@ -644,7 +644,7 @@ describe('mocks', function() { expect(response).toBe('def-response'); }); - hb.when('GET').then(201, 'def-response'); + hb.when('GET').respond(201, 'def-response'); hb.expect('GET', '/some-url'); hb('GET', '/some-url', null, callback); @@ -671,7 +671,7 @@ describe('mocks', function() { it('should respond undefined when JSONP method', function() { - hb.when('JSONP', '/url1').then(200); + hb.when('JSONP', '/url1').respond(200); hb.expect('JSONP', '/url2').respond(200); expect(hb('JSONP', '/url1')).toBeUndefined(); @@ -694,7 +694,7 @@ describe('mocks', function() { it('should do nothing when no expectation', function() { - hb.when('DELETE', '/some').then(200, ''); + hb.when('DELETE', '/some').respond(200, ''); expect(function() {hb.verifyNoOutstandingExpectation();}).not.toThrow(); }); @@ -703,7 +703,7 @@ describe('mocks', function() { it('should do nothing when all expectations satisfied', function() { hb.expect('GET', '/u2').respond(200, '', {}); hb.expect('POST', '/u3').respond(201, '', {}); - hb.when('DELETE', '/some').then(200, ''); + hb.when('DELETE', '/some').respond(200, ''); hb('GET', '/u2', noop); hb('POST', '/u3', noop); @@ -715,7 +715,7 @@ describe('mocks', function() { describe('verifyRequests', function() { it('should throw exception if not all requests were flushed', function() { - hb.when('GET').then(200); + hb.when('GET').respond(200); hb('GET', '/some', null, noop, {}); expect(function() { diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 72fd5f8e5468..8212eb074cb9 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -411,7 +411,7 @@ describe('$http', function() { var future, rawXhrObject; beforeEach(function() { - $httpBackend.when('GET', '/url').then(''); + $httpBackend.when('GET', '/url').respond(''); future = $http({method: 'GET', url: '/url'}); rawXhrObject = MockXhr.$$lastInstance; spyOn(rawXhrObject, 'abort'); @@ -490,7 +490,7 @@ describe('$http', function() { } beforeEach(function() { - $httpBackend.when('GET').then(function(m, url) { + $httpBackend.when('GET').respond(function(m, url) { return [parseInt(url.substr(1)), '', {}]; }); }); @@ -658,7 +658,7 @@ describe('$http', function() { describe('scope.$apply', function() { it('should $apply after success callback', function() { - $httpBackend.when('GET').then(200); + $httpBackend.when('GET').respond(200); $http({method: 'GET', url: '/some'}); $httpBackend.flush(); expect(scope.$apply).toHaveBeenCalledOnce(); @@ -666,7 +666,7 @@ describe('$http', function() { it('should $apply after error callback', function() { - $httpBackend.when('GET').then(404); + $httpBackend.when('GET').respond(404); $http({method: 'GET', url: '/some'}); $httpBackend.flush(); expect(scope.$apply).toHaveBeenCalledOnce(); @@ -674,7 +674,7 @@ describe('$http', function() { it('should $apply even if exception thrown during callback', function() { - $httpBackend.when('GET').then(200); + $httpBackend.when('GET').respond(200); callback.andThrow('error in callback'); $http({method: 'GET', url: '/some'}).on('200', callback); @@ -687,7 +687,7 @@ describe('$http', function() { it('should broadcast $http.request', function() { - $httpBackend.when('GET').then(200); + $httpBackend.when('GET').respond(200); scope.$on('$http.request', callback); var xhrFuture = $http({method: 'GET', url: '/whatever'}); @@ -880,7 +880,7 @@ describe('$http', function() { describe('pendingRequests', function() { it('should be an array of pending requests', function() { - $httpBackend.when('GET').then(200); + $httpBackend.when('GET').respond(200); expect($http.pendingRequests.length).toBe(0); $http({method: 'get', url: '/some'}); @@ -892,7 +892,7 @@ describe('$http', function() { it('should remove the request when aborted', function() { - $httpBackend.when('GET').then(0); + $httpBackend.when('GET').respond(0); future = $http({method: 'get', url: '/x'}); expect($http.pendingRequests.length).toBe(1); @@ -904,7 +904,7 @@ describe('$http', function() { it('should remove the request when served from cache', function() { - $httpBackend.when('GET').then(200); + $httpBackend.when('GET').respond(200); $http({method: 'get', url: '/cached', cache: true}); $httpBackend.flush(); @@ -919,7 +919,7 @@ describe('$http', function() { it('should remove the request before firing callbacks', function() { - $httpBackend.when('GET').then(200); + $httpBackend.when('GET').respond(200); $http({method: 'get', url: '/url'}).on('xxx', function() { expect($http.pendingRequests.length).toBe(0); }); From 033dac68f690791ae98476cc58294dca7bf63f98 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Tue, 1 Nov 2011 13:40:51 -0700 Subject: [PATCH 22/28] feat(mock.$httpBackend): verify expectations after flush() --- src/angular-mocks.js | 1 + test/ResourceSpec.js | 9 ++++----- test/angular-mocksSpec.js | 9 +++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index d9535f6494ce..60211e6f1da6 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -667,6 +667,7 @@ angular.module.ngMock.$HttpBackendProvider = function() { while (responses.length) responses.shift()(); } + $httpBackend.verifyNoOutstandingExpectation(); }; $httpBackend.verifyNoOutstandingExpectation = function() { diff --git a/test/ResourceSpec.js b/test/ResourceSpec.js index fd7e41dbcb2d..7987af044b56 100644 --- a/test/ResourceSpec.js +++ b/test/ResourceSpec.js @@ -125,8 +125,6 @@ describe("resource", function() { it("should read partial resource", inject(function($httpBackend) { $httpBackend.expect('GET', '/CreditCard').respond([{id:{key:123}}]); - $httpBackend.expect('GET', '/CreditCard/123').respond({id: {key: 123}, number: '9876'}); - var ccs = CreditCard.query(); $httpBackend.flush(); @@ -136,6 +134,7 @@ describe("resource", function() { expect(cc instanceof CreditCard).toBe(true); expect(cc.number).toBeUndefined(); + $httpBackend.expect('GET', '/CreditCard/123').respond({id: {key: 123}, number: '9876'}); cc.$get(callback); $httpBackend.flush(); expect(callback).toHaveBeenCalledWith(cc); @@ -177,8 +176,6 @@ describe("resource", function() { it('should delete resource and call callback', inject(function($httpBackend) { $httpBackend.expect('DELETE', '/CreditCard/123').respond({}); - $httpBackend.expect('DELETE', '/CreditCard/333').respond(204, null); - CreditCard.remove({id:123}, callback); expect(callback).not.toHaveBeenCalled(); @@ -186,6 +183,7 @@ describe("resource", function() { nakedExpect(callback.mostRecentCall.args).toEqual([{}]); callback.reset(); + $httpBackend.expect('DELETE', '/CreditCard/333').respond(204, null); CreditCard.remove({id:333}, callback); expect(callback).not.toHaveBeenCalled(); @@ -227,13 +225,14 @@ describe("resource", function() { it('should not mutate the resource object if response contains no body', inject(function($httpBackend) { var data = {id:{key:123}, number:'9876'}; $httpBackend.expect('GET', '/CreditCard/123').respond(data); - $httpBackend.expect('POST', '/CreditCard/123', toJson(data)).respond(''); var cc = CreditCard.get({id:123}); $httpBackend.flush(); expect(cc instanceof CreditCard).toBe(true); + $httpBackend.expect('POST', '/CreditCard/123', toJson(data)).respond(''); var idBefore = cc.id; + cc.$save(); $httpBackend.flush(); expect(idBefore).toEqual(cc.id); diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index 01c1b6ca4db3..6b6f00526b3b 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -606,6 +606,15 @@ describe('mocks', function() { }); + it('(flush) should throw exception if not all expectations satasfied', function() { + hb.expect('GET', '/url1').respond(); + hb.expect('GET', '/url2').respond(); + + hb('GET', '/url1', null, angular.noop); + expect(function() {hb.flush();}).toThrow('Unsatisfied requests: GET /url2'); + }); + + it('respond() should set default status 200 if not defined', function() { callback.andCallFake(function(status, response) { expect(status).toBe(200); From cff7b9739a1db2984d43cb36cfbe1b5baae622af Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Thu, 3 Nov 2011 15:17:32 -0700 Subject: [PATCH 23/28] feat(mock.$httpBackend): say which request was expected when unexpected request error --- src/angular-mocks.js | 6 ++++-- test/angular-mocksSpec.js | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 60211e6f1da6..6f69f0d6baf5 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -631,8 +631,10 @@ angular.module.ngMock.$HttpBackendProvider = function() { return method == 'JSONP' ? undefined : xhr; } } - throw wasExpected ? Error('No response defined !') : - Error('Unexpected request: ' + method + ' ' + url); + throw wasExpected ? + Error('No response defined !') : + Error('Unexpected request: ' + method + ' ' + url + '\n' + + (expectation ? 'Expected ' + expectation : 'No more request expected')); } $httpBackend.when = function(method, url, data, headers) { diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index 6b6f00526b3b..e117c26d8f28 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -399,7 +399,7 @@ describe('mocks', function() { hb.when('GET', '/url1').respond(200, 'content'); expect(function() { hb('GET', '/xxx'); - }).toThrow('Unexpected request: GET /xxx'); + }).toThrow('Unexpected request: GET /xxx\nNo more request expected'); }); @@ -506,7 +506,7 @@ describe('mocks', function() { expect(function() { hb('GET', '/url2', null, noop, {}); - }).toThrow('Unexpected request: GET /url2'); + }).toThrow('Unexpected request: GET /url2\nExpected GET /url1'); }); From e24923123c0b8929b9ee8d2a408125acacb0bcd2 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Thu, 3 Nov 2011 17:14:03 -0700 Subject: [PATCH 24/28] feat($httpBackend): fix 0 status code when "file" protocol Browsers return always 0 status code for "file" protocol, so we convert them into 200/404. --- src/service/httpBackend.js | 25 ++++++++++----- test/service/httpBackendSpec.js | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/service/httpBackend.js b/src/service/httpBackend.js index 287009403fc0..ea7aa264c446 100644 --- a/src/service/httpBackend.js +++ b/src/service/httpBackend.js @@ -17,19 +17,14 @@ var XHR = window.XMLHttpRequest || function() { */ function $HttpBackendProvider() { this.$get = ['$browser', '$window', '$document', function($browser, $window, $document) { - return createHttpBackend($browser, XHR, $browser.defer, $window, $document[0].body); + return createHttpBackend($browser, XHR, $browser.defer, $window, $document[0].body, + $window.location.replace(':', '')); }]; } -function createHttpBackend($browser, XHR, $browserDefer, $window, body) { +function createHttpBackend($browser, XHR, $browserDefer, $window, body, locationProtocol) { var idCounter = 0; - function completeRequest(callback, status, response) { - // normalize IE bug (http://bugs.jquery.com/ticket/1450) - callback(status == 1223 ? 204 : status, response); - $browser.$$completeOutstandingRequest(noop); - } - // TODO(vojta): fix the signature return function(method, url, post, callback, headers, timeout) { $browser.$$incOutstandingRequestCount(); @@ -81,6 +76,20 @@ function createHttpBackend($browser, XHR, $browserDefer, $window, body) { return xhr; } + + function completeRequest(callback, status, response) { + // URL_MATCH is defined in src/service/location.js + var protocol = (url.match(URL_MATCH) || ['', locationProtocol])[1]; + + // fix status code for file protocol (it's always 0) + status = protocol == 'file' ? (response ? 200 : 404) : status; + + // normalize IE bug (http://bugs.jquery.com/ticket/1450) + status = status == 1223 ? 204 : status; + + callback(status, response); + $browser.$$completeOutstandingRequest(noop); + } }; } diff --git a/test/service/httpBackendSpec.js b/test/service/httpBackendSpec.js index e609eea6f175..ccd9e4b68405 100644 --- a/test/service/httpBackendSpec.js +++ b/test/service/httpBackendSpec.js @@ -175,5 +175,60 @@ describe('$httpBackend', function() { // TODO(vojta): test whether it fires "async-start" // TODO(vojta): test whether it fires "async-end" on both success and error }); + + describe('file protocol', function() { + + function respond(status, content) { + xhr = MockXhr.$$lastInstance; + xhr.status = status; + xhr.responseText = content; + xhr.readyState = 4; + xhr.onreadystatechange(); + } + + + it('should convert 0 to 200 if content', function() { + $backend = createHttpBackend($browser, MockXhr, null, null, null, 'http'); + + $backend('GET', 'file:///whatever/index.html', null, callback); + respond(0, 'SOME CONTENT'); + + expect(callback).toHaveBeenCalled(); + expect(callback.mostRecentCall.args[0]).toBe(200); + }); + + + it('should convert 0 to 200 if content - relative url', function() { + $backend = createHttpBackend($browser, MockXhr, null, null, null, 'file'); + + $backend('GET', '/whatever/index.html', null, callback); + respond(0, 'SOME CONTENT'); + + expect(callback).toHaveBeenCalled(); + expect(callback.mostRecentCall.args[0]).toBe(200); + }); + + + it('should convert 0 to 404 if no content', function() { + $backend = createHttpBackend($browser, MockXhr, null, null, null, 'http'); + + $backend('GET', 'file:///whatever/index.html', null, callback); + respond(0, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.mostRecentCall.args[0]).toBe(404); + }); + + + it('should convert 0 to 200 if content - relative url', function() { + $backend = createHttpBackend($browser, MockXhr, null, null, null, 'file'); + + $backend('GET', '/whatever/index.html', null, callback); + respond(0, ''); + + expect(callback).toHaveBeenCalled(); + expect(callback.mostRecentCall.args[0]).toBe(404); + }); + }); }); From 55cebd4531b44e8dd9314aa85e6f583fb331ba4d Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 4 Nov 2011 16:34:47 -0700 Subject: [PATCH 25/28] feat($http): allow passing custom cache instance per request You can still use cache: true, which will use $http's default cache. --- src/service/http.js | 56 +++++++++++++++++++++++++-------- test/service/httpSpec.js | 67 +++++++++++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/src/service/http.js b/src/service/http.js index 23f33fadd71a..d7ad9dde668f 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -92,7 +92,7 @@ function $HttpProvider() { this.$get = ['$httpBackend', '$browser', '$exceptionHandler', '$cacheFactory', '$rootScope', function($httpBackend, $browser, $exceptionHandler, $cacheFactory, $rootScope) { - var cache = $cacheFactory('$http'); + var defaultCache = $cacheFactory('$http'); // the actual service function $http(config) { @@ -226,7 +226,7 @@ function $HttpProvider() { * Represents Request object, returned by $http() * * !!! ACCESS CLOSURE VARS: - * $httpBackend, $browser, $config, $log, $rootScope, cache, $http.pendingRequests + * $httpBackend, $browser, $config, $log, $rootScope, defaultCache, $http.pendingRequests */ function XhrFuture() { var rawRequest, parsedHeaders, @@ -244,9 +244,15 @@ function $HttpProvider() { // aborted request or jsonp if (!rawRequest) parsedHeaders = {}; - if (cfg.cache && cfg.method == 'GET' && 200 <= status && status < 300) { - parsedHeaders = parsedHeaders || parseHeaders(rawRequest.getAllResponseHeaders()); - cache.put(cfg.url, [status, response, parsedHeaders]); + if (cfg.cache && cfg.method == 'GET') { + var cache = isObject(cfg.cache) && cfg.cache || defaultCache; + if (200 <= status && status < 300) { + parsedHeaders = parsedHeaders || parseHeaders(rawRequest.getAllResponseHeaders()); + cache.put(cfg.url, [status, response, parsedHeaders]); + } else { + // remove future object from cache + cache.remove(cfg.url); + } } fireCallbacks(response, status); @@ -333,19 +339,43 @@ function $HttpProvider() { headers = extend({'X-XSRF-TOKEN': $browser.cookies()['XSRF-TOKEN']}, defHeaders.common, defHeaders[lowercase(cfg.method)], cfg.headers); - var fromCache; - if (cfg.cache && cfg.method == 'GET' && (fromCache = cache.get(cfg.url))) { - $browser.defer(function() { - parsedHeaders = fromCache[2]; - fireCallbacks(fromCache[1], fromCache[0]); - }); - } else { + var cache = isObject(cfg.cache) && cfg.cache || defaultCache, + fromCache; + + if (cfg.cache && cfg.method == 'GET') { + fromCache = cache.get(cfg.url); + if (fromCache) { + if (fromCache instanceof XhrFuture) { + // cached request has already been sent, but there is no reponse yet, + // we need to register callback and fire callbacks when the request is back + // note, we have to get the values from cache and perform transformations on them, + // as the configurations don't have to be same + fromCache.on('always', function() { + var requestFromCache = cache.get(cfg.url); + parsedHeaders = requestFromCache[2]; + fireCallbacks(requestFromCache[1], requestFromCache[0]); + }); + } else { + // serving from cache - still needs to be async + $browser.defer(function() { + parsedHeaders = fromCache[2]; + fireCallbacks(fromCache[1], fromCache[0]); + }); + } + } else { + // put future object into cache + cache.put(cfg.url, self); + } + } + + // really send the request + if (!cfg.cache || cfg.method !== 'GET' || !fromCache) { rawRequest = $httpBackend(cfg.method, cfg.url, data, done, headers, cfg.timeout); } $rootScope.$broadcast('$http.request', self); $http.pendingRequests.push(self); - return this; + return self; }; // just alias so that in stack trace we can see send() instead of retry() diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 8212eb074cb9..a235426e805a 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -779,16 +779,22 @@ describe('$http', function() { describe('cache', function() { + var cache; + + beforeEach(inject(function($cacheFactory) { + cache = $cacheFactory('testCache'); + })); + function doFirstCacheRequest(method, respStatus, headers) { $httpBackend.expect(method || 'GET', '/url').respond(respStatus || 200, 'content', headers); - $http({method: method || 'GET', url: '/url', cache: true}); + $http({method: method || 'GET', url: '/url', cache: cache}); $httpBackend.flush(); } - it('should cache GET request', function() { + it('should cache GET request when cache is provided', function() { doFirstCacheRequest(); - $http({method: 'get', url: '/url', cache: true}).on('200', callback); + $http({method: 'get', url: '/url', cache: cache}).on('200', callback); $browser.defer.flush(); expect(callback).toHaveBeenCalledOnce(); @@ -796,11 +802,28 @@ describe('$http', function() { }); + it('should not cache when cache is not provided', function() { + doFirstCacheRequest(); + + $httpBackend.expect('GET', '/url').respond(); + $http({method: 'GET', url: '/url'}); + }); + + + it('should perform request when cache cleared', function() { + doFirstCacheRequest(); + + cache.removeAll(); + $httpBackend.expect('GET', '/url').respond(); + $http({method: 'GET', url: '/url', cache: cache}); + }); + + it('should always call callback asynchronously', function() { doFirstCacheRequest(); - $http({method: 'get', url: '/url', cache: true}).on('200', callback); + $http({method: 'get', url: '/url', cache: cache}).on('200', callback); - expect(callback).not.toHaveBeenCalledOnce(); + expect(callback).not.toHaveBeenCalled(); }); @@ -808,7 +831,7 @@ describe('$http', function() { doFirstCacheRequest('POST'); $httpBackend.expect('POST', '/url').respond('content2'); - $http({method: 'POST', url: '/url', cache: true}).on('200', callback); + $http({method: 'POST', url: '/url', cache: cache}).on('200', callback); $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); @@ -820,7 +843,7 @@ describe('$http', function() { doFirstCacheRequest('PUT'); $httpBackend.expect('PUT', '/url').respond('content2'); - $http({method: 'PUT', url: '/url', cache: true}).on('200', callback); + $http({method: 'PUT', url: '/url', cache: cache}).on('200', callback); $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); @@ -832,7 +855,7 @@ describe('$http', function() { doFirstCacheRequest('DELETE'); $httpBackend.expect('DELETE', '/url').respond(206); - $http({method: 'DELETE', url: '/url', cache: true}).on('206', callback); + $http({method: 'DELETE', url: '/url', cache: cache}).on('206', callback); $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); @@ -843,7 +866,7 @@ describe('$http', function() { doFirstCacheRequest('GET', 404); $httpBackend.expect('GET', '/url').respond('content2'); - $http({method: 'GET', url: '/url', cache: true}).on('200', callback); + $http({method: 'GET', url: '/url', cache: cache}).on('200', callback); $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); @@ -858,7 +881,7 @@ describe('$http', function() { expect(headers('server')).toBe('Apache'); }); - $http({method: 'GET', url: '/url', cache: true}).on('200', callback); + $http({method: 'GET', url: '/url', cache: cache}).on('200', callback); $browser.defer.flush(); expect(callback).toHaveBeenCalledOnce(); }); @@ -870,10 +893,27 @@ describe('$http', function() { expect(status).toBe(201); }); - $http({method: 'get', url: '/url', cache: true}).on('2xx', callback); + $http({method: 'get', url: '/url', cache: cache}).on('2xx', callback); $browser.defer.flush(); expect(callback).toHaveBeenCalledOnce(); }); + + + it('should use cache even if request fired before first response is back', function() { + $httpBackend.expect('GET', '/url').respond(201, 'fake-response'); + + callback.andCallFake(function(response, status, headers) { + expect(response).toBe('fake-response'); + expect(status).toBe(201); + }); + + $http({method: 'GET', url: '/url', cache: cache}).on('always', callback); + $http({method: 'GET', url: '/url', cache: cache}).on('always', callback); + + $httpBackend.flush(); + expect(callback).toHaveBeenCalled(); + expect(callback.callCount).toBe(2); + }); }); @@ -903,10 +943,13 @@ describe('$http', function() { }); - it('should remove the request when served from cache', function() { + it('should update pending requests even when served from cache', function() { $httpBackend.when('GET').respond(200); $http({method: 'get', url: '/cached', cache: true}); + $http({method: 'get', url: '/cached', cache: true}); + expect($http.pendingRequests.length).toBe(2); + $httpBackend.flush(); expect($http.pendingRequests.length).toBe(0); From 0ef4d4390edc36ac3f37020bb73848e0478c55b2 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 4 Nov 2011 17:15:03 -0700 Subject: [PATCH 26/28] style(): get rid off some jsl warnings --- src/service/http.js | 14 +++++++++----- src/service/location.js | 14 +++++++------- src/widgets.js | 6 +++--- test/angular-mocksSpec.js | 4 ++-- test/service/httpSpec.js | 34 +++++++++++++++++----------------- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/service/http.js b/src/service/http.js index d7ad9dde668f..049dbd50ae30 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -61,13 +61,17 @@ function transform(data, fns, param) { * @description */ function $HttpProvider() { + var JSON_START = /^\s*[\[\{]/, + JSON_END = /[\}\]]\s*$/, + PROTECTION_PREFIX = /^\)\]\}',?\n/; + var $config = this.defaults = { // transform in-coming reponse data transformResponse: function(data) { if (isString(data)) { // strip json vulnerability protection prefix - data = data.replace(/^\)\]\}',?\n/, ''); - if (/^\s*[\[\{]/.test(data) && /[\}\]]\s*$/.test(data)) + data = data.replace(PROTECTION_PREFIX, ''); + if (JSON_START.test(data) && JSON_END.test(data)) data = fromJson(data, true); } return data; @@ -313,9 +317,9 @@ function $HttpProvider() { */ function headers(name) { if (name) { - return parsedHeaders - ? parsedHeaders[lowercase(name)] || null - : rawRequest.getResponseHeader(name); + return parsedHeaders ? + parsedHeaders[lowercase(name)] || null : + rawRequest.getResponseHeader(name); } parsedHeaders = parsedHeaders || parseHeaders(rawRequest.getAllResponseHeaders()); diff --git a/src/service/location.js b/src/service/location.js index a29a1a157579..06f2578fbeb7 100644 --- a/src/service/location.js +++ b/src/service/location.js @@ -25,12 +25,12 @@ function encodePath(path) { function matchUrl(url, obj) { - var match = URL_MATCH.exec(url), + var match = URL_MATCH.exec(url); match = { protocol: match[1], host: match[3], - port: parseInt(match[5]) || DEFAULT_PORTS[match[1]] || null, + port: parseInt(match[5], 10) || DEFAULT_PORTS[match[1]] || null, path: match[6] || '/', search: match[8], hash: match[10] @@ -61,7 +61,7 @@ function convertToHtml5Url(url, basePath, hashPrefix) { // already html5 url if (decodeURIComponent(match.path) != basePath || isUndefined(match.hash) || - match.hash.indexOf(hashPrefix) != 0) { + match.hash.indexOf(hashPrefix) !== 0) { return url; // convert hashbang url -> html5 url } else { @@ -84,7 +84,7 @@ function convertToHashbangUrl(url, basePath, hashPrefix) { pathPrefix = pathPrefixFromBase(basePath), path = match.path.substr(pathPrefix.length); - if (match.path.indexOf(pathPrefix) != 0) { + if (match.path.indexOf(pathPrefix) !== 0) { throw 'Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !'; } @@ -113,7 +113,7 @@ function LocationUrl(url, pathPrefix) { this.$$parse = function(url) { var match = matchUrl(url, this); - if (match.path.indexOf(pathPrefix) != 0) { + if (match.path.indexOf(pathPrefix) !== 0) { throw 'Invalid url "' + url + '", missing path prefix "' + pathPrefix + '" !'; } @@ -122,7 +122,7 @@ function LocationUrl(url, pathPrefix) { this.$$hash = match.hash && decodeURIComponent(match.hash) || ''; this.$$compose(); - }, + }; /** * Compose url and update `absUrl` property @@ -160,7 +160,7 @@ function LocationHashbangUrl(url, hashPrefix) { this.$$parse = function(url) { var match = matchUrl(url, this); - if (match.hash && match.hash.indexOf(hashPrefix) != 0) { + if (match.hash && match.hash.indexOf(hashPrefix) !== 0) { throw 'Invalid url "' + url + '", missing hash prefix "' + hashPrefix + '" !'; } diff --git a/src/widgets.js b/src/widgets.js index b6fb81b659c0..0d0e85a5e641 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -432,9 +432,9 @@ angularWidget('@ng:repeat', function(expression, element){ childScope[valueIdent] = value; if (keyIdent) childScope[keyIdent] = key; childScope.$index = index; - childScope.$position = index == 0 - ? 'first' - : (index == collectionLength - 1 ? 'last' : 'middle'); + childScope.$position = index === 0 ? + 'first' : + (index == collectionLength - 1 ? 'last' : 'middle'); if (!last) { linker(childScope, function(clone){ diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index e117c26d8f28..308149854c23 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -697,8 +697,8 @@ describe('mocks', function() { hb('POST', '/u1', 'ddd', noop, {}); - expect(function() {hb.verifyNoOutstandingExpectation();}) - .toThrow('Unsatisfied requests: GET /u2, POST /u3'); + expect(function() {hb.verifyNoOutstandingExpectation();}). + toThrow('Unsatisfied requests: GET /u2, POST /u3'); }); diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index a235426e805a..83a6e860c5ba 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -61,9 +61,9 @@ describe('$http', function() { it('should log more exceptions', function() { $httpBackend.expect('GET', '/url').respond(500, ''); - $http({url: '/url', method: 'GET'}) - .on('500', throwing('exception in error callback')) - .on('5xx', throwing('exception in error callback')); + $http({url: '/url', method: 'GET'}). + on('500', throwing('exception in error callback')). + on('5xx', throwing('exception in error callback')); $httpBackend.flush(); expect($exceptionHandler.errors.length).toBe(2); @@ -491,7 +491,7 @@ describe('$http', function() { beforeEach(function() { $httpBackend.when('GET').respond(function(m, url) { - return [parseInt(url.substr(1)), '', {}]; + return [parseInt(url.substr(1), 10), '', {}]; }); }); @@ -550,13 +550,13 @@ describe('$http', function() { it('should call all matched callbacks', function() { var no = jasmine.createSpy('wrong'); - $http({method: 'GET', url: '/205'}) - .on('xxx', callback) - .on('2xx', callback) - .on('205', callback) - .on('3xx', no) - .on('2x1', no) - .on('4xx', no); + $http({method: 'GET', url: '/205'}). + on('xxx', callback). + on('2xx', callback). + on('205', callback). + on('3xx', no). + on('2x1', no). + on('4xx', no); $httpBackend.flush(); @@ -576,10 +576,10 @@ describe('$http', function() { it('should preserve the order of listeners', function() { var log = ''; - $http({method: 'GET', url: '/201'}) - .on('2xx', function() {log += '1';}) - .on('201', function() {log += '2';}) - .on('2xx', function() {log += '3';}); + $http({method: 'GET', url: '/201'}). + on('2xx', function() {log += '1';}). + on('201', function() {log += '2';}). + on('2xx', function() {log += '3';}); $httpBackend.flush(); expect(log).toBe('123'); @@ -766,8 +766,8 @@ describe('$http', function() { function second(d) {return d + '2';} $httpBackend.expect('POST', '/url').respond('0'); - $http({method: 'POST', url: '/url', transformResponse: [first, second]}) - .on('200', callback); + $http({method: 'POST', url: '/url', transformResponse: [first, second]}). + on('200', callback); $httpBackend.flush(); expect(callback).toHaveBeenCalledOnce(); From 0144ede45794675e68e40747002a55bac8e15170 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 4 Nov 2011 18:02:47 -0700 Subject: [PATCH 27/28] fix($http): default json transformation should not crash on angular template The way we determine whether it's json is lame anyway. We need to change that. We should probably check the content type header... --- src/service/http.js | 2 +- test/service/httpSpec.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/service/http.js b/src/service/http.js index 049dbd50ae30..6efe747400b3 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -61,7 +61,7 @@ function transform(data, fns, param) { * @description */ function $HttpProvider() { - var JSON_START = /^\s*[\[\{]/, + var JSON_START = /^\s*(\[|\{[^\{])/, JSON_END = /[\}\]]\s*$/, PROTECTION_PREFIX = /^\)\]\}',?\n/; diff --git a/test/service/httpSpec.js b/test/service/httpSpec.js index 83a6e860c5ba..210020f43853 100644 --- a/test/service/httpSpec.js +++ b/test/service/httpSpec.js @@ -758,6 +758,16 @@ describe('$http', function() { expect(callback).toHaveBeenCalledOnce(); expect(callback.mostRecentCall.args[0]).toEqual([1, 'abc', {foo:'bar'}]); }); + + + it('should not deserialize tpl beginning with ng expression', function() { + $httpBackend.expect('GET', '/url').respond('{{some}}'); + $http.get('/url').on('200', callback); + $httpBackend.flush(); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback.mostRecentCall.args[0]).toEqual('{{some}}'); + }); }); From 01b36775fd22d040e80c0ee9050b27e9c8cdbec0 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 4 Nov 2011 18:33:12 -0700 Subject: [PATCH 28/28] refactor(ng:view, ng:include): pass cache instance into $http Instead of doing all the stuff in these widgets (checking cache, etc..) we can rely on $http now... --- src/widgets.js | 57 +++++++++++++-------------------------- test/widgetsSpec.js | 66 ++++++++++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 61 deletions(-) diff --git a/src/widgets.js b/src/widgets.js index 0d0e85a5e641..4782f28ca42a 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -111,17 +111,6 @@ angularWidget('ng:include', function(element){ useScope = scope.$eval(scopeExp), fromCache; - function updateContent(content) { - element.html(content); - if (useScope) { - childScope = useScope; - } else { - releaseScopes.push(childScope = scope.$new()); - } - compiler.compile(element)(childScope); - scope.$eval(onloadExp); - } - function clearContent() { childScope = null; element.html(''); @@ -131,16 +120,16 @@ angularWidget('ng:include', function(element){ releaseScopes.pop().$destroy(); } if (src) { - if ((fromCache = $cache.get(src))) { - scope.$evalAsync(function() { - updateContent(fromCache); - }); - } else { - $http.get(src).on('success', function(response) { - updateContent(response); - $cache.put(src, response); - }).on('error', clearContent); - } + $http.get(src, {cache: $cache}).on('success', function(response) { + element.html(response); + if (useScope) { + childScope = useScope; + } else { + releaseScopes.push(childScope = scope.$new()); + } + compiler.compile(element)(childScope); + scope.$eval(onloadExp); + }).on('error', clearContent); } else { clearContent(); } @@ -583,29 +572,19 @@ angularWidget('ng:view', function(element) { var template = $route.current && $route.current.template, fromCache; - function updateContent(content) { - element.html(content); - compiler.compile(element)($route.current.scope); - } - function clearContent() { element.html(''); } if (template) { - if ((fromCache = $cache.get(template))) { - scope.$evalAsync(function() { - updateContent(fromCache); - }); - } else { - // xhr's callback must be async, see commit history for more info - $http.get(template).on('success', function(response) { - // ignore callback if another route change occured since - if (newChangeCounter == changeCounter) - updateContent(response); - $cache.put(template, response); - }).on('error', clearContent); - } + // xhr's callback must be async, see commit history for more info + $http.get(template, {cache: $cache}).on('success', function(response) { + // ignore callback if another route change occured since + if (newChangeCounter == changeCounter) { + element.html(response); + compiler.compile(element)($route.current.scope); + } + }).on('error', clearContent); } else { clearContent(); } diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index 36e9494720c3..fbc44990e4e8 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -56,27 +56,37 @@ describe("widget", function() { describe('ng:include', inject(function($rootScope, $compile) { - it('should include on external file', inject(function($rootScope, $compile, $cacheFactory) { + + function putIntoCache(url, content) { + return function($templateCache) { + $templateCache.put(url, [200, content, {}]); + }; + } + + + it('should include on external file', inject(putIntoCache('myUrl', '{{name}}'), + function($rootScope, $compile, $browser) { var element = jqLite(''); var element = $compile(element)($rootScope); $rootScope.childScope = $rootScope.$new(); $rootScope.childScope.name = 'misko'; $rootScope.url = 'myUrl'; - $cacheFactory.get('templates').put('myUrl', '{{name}}'); $rootScope.$digest(); + $browser.defer.flush(); expect(element.text()).toEqual('misko'); })); - - it('should remove previously included text if a falsy value is bound to src', - inject(function($rootScope, $compile, $cacheFactory) { + + it('should remove previously included text if a falsy value is bound to src', inject( + putIntoCache('myUrl', '{{name}}'), + function($rootScope, $compile, $browser) { var element = jqLite(''); var element = $compile(element)($rootScope); $rootScope.childScope = $rootScope.$new(); $rootScope.childScope.name = 'igor'; $rootScope.url = 'myUrl'; - $cacheFactory.get('templates').put('myUrl', '{{name}}'); $rootScope.$digest(); + $browser.defer.flush(); expect(element.text()).toEqual('igor'); @@ -86,13 +96,15 @@ describe("widget", function() { expect(element.text()).toEqual(''); })); - - it('should allow this for scope', inject(function($rootScope, $compile, $cacheFactory) { + + it('should allow this for scope', inject(putIntoCache('myUrl', '{{"abc"}}'), + function($rootScope, $compile, $browser) { var element = jqLite(''); var element = $compile(element)($rootScope); $rootScope.url = 'myUrl'; - $cacheFactory.get('templates').put('myUrl', '{{"abc"}}'); $rootScope.$digest(); + $browser.defer.flush(); + // TODO(misko): because we are using scope==this, the eval gets registered // during the flush phase and hence does not get called. // I don't think passing 'this' makes sense. Does having scope on ng:include makes sense? @@ -102,31 +114,34 @@ describe("widget", function() { expect(element.text()).toEqual('abc'); })); - - it('should evaluate onload expression when a partial is loaded', - inject(function($rootScope, $compile, $cacheFactory) { + + it('should evaluate onload expression when a partial is loaded', inject( + putIntoCache('myUrl', 'my partial'), + function($rootScope, $compile, $browser) { var element = jqLite(''); var element = $compile(element)($rootScope); expect($rootScope.loaded).not.toBeDefined(); $rootScope.url = 'myUrl'; - $cacheFactory.get('templates').put('myUrl', 'my partial'); $rootScope.$digest(); + $browser.defer.flush(); + expect(element.text()).toEqual('my partial'); expect($rootScope.loaded).toBe(true); })); - - it('should destroy old scope', inject(function($rootScope, $compile, $cacheFactory) { + + it('should destroy old scope', inject(putIntoCache('myUrl', 'my partial'), + function($rootScope, $compile, $browser) { var element = jqLite(''); var element = $compile(element)($rootScope); expect($rootScope.$$childHead).toBeFalsy(); $rootScope.url = 'myUrl'; - $cacheFactory.get('templates').put('myUrl', 'my partial'); $rootScope.$digest(); + $browser.defer.flush(); expect($rootScope.$$childHead).toBeTruthy(); $rootScope.url = null; @@ -134,7 +149,8 @@ describe("widget", function() { expect($rootScope.$$childHead).toBeFalsy(); })); - it('should do xhr request and cache it', inject(function($rootScope, $httpBackend, $compile) { + it('should do xhr request and cache it', + inject(function($rootScope, $httpBackend, $compile, $browser) { var element = $compile('')($rootScope); $httpBackend.expect('GET', 'myUrl').respond('my partial'); @@ -149,6 +165,7 @@ describe("widget", function() { $rootScope.url = 'myUrl'; $rootScope.$digest(); + $browser.defer.flush(); expect(element.text()).toEqual('my partial'); dealoc($rootScope); })); @@ -165,11 +182,12 @@ describe("widget", function() { expect(element.text()).toBe(''); })); - it('should be async even if served from cache', inject(function($rootScope, $compile, $cacheFactory) { + it('should be async even if served from cache', inject( + putIntoCache('myUrl', 'my partial'), + function($rootScope, $compile, $browser) { var element = $compile('')($rootScope); $rootScope.url = 'myUrl'; - $cacheFactory.get('templates').put('myUrl', 'my partial'); var called = 0; // we want to assert only during first watch @@ -178,6 +196,7 @@ describe("widget", function() { }); $rootScope.$digest(); + $browser.defer.flush(); expect(element.text()).toBe('my partial'); })); })); @@ -574,7 +593,8 @@ describe("widget", function() { })); it('should initialize view template after the view controller was initialized even when ' + - 'templates were cached', inject(function($rootScope, $compile, $location, $httpBackend, $route) { + 'templates were cached', + inject(function($rootScope, $compile, $location, $httpBackend, $route, $browser) { //this is a test for a regression that was introduced by making the ng:view cache sync $route.when('/foo', {controller: ParentCtrl, template: 'viewPartial.html'}); @@ -605,6 +625,7 @@ describe("widget", function() { $rootScope.log = []; $location.path('/foo'); $rootScope.$apply(); + $browser.defer.flush(); expect($rootScope.log).toEqual(['parent', 'init', 'child']); })); @@ -644,9 +665,9 @@ describe("widget", function() { })); it('should be async even if served from cache', - inject(function($route, $rootScope, $location, $cacheFactory) { + inject(function($route, $rootScope, $location, $templateCache, $browser) { + $templateCache.put('myUrl1', [200, 'my partial', {}]); $route.when('/foo', {controller: noop, template: 'myUrl1'}); - $cacheFactory.get('templates').put('myUrl1', 'my partial'); $location.path('/foo'); var called = 0; @@ -656,6 +677,7 @@ describe("widget", function() { }); $rootScope.$digest(); + $browser.defer.flush(); expect(element.text()).toBe('my partial'); })); });