diff --git a/lib/mock/http_backend.dart b/lib/mock/http_backend.dart index 2d472ccd8..47662f949 100644 --- a/lib/mock/http_backend.dart +++ b/lib/mock/http_backend.dart @@ -75,37 +75,37 @@ class MockHttpExpectation { MockHttpExpectation(this.method, this.url, [this.data, this.headers, withCredentials]) : this.withCredentials = withCredentials == true; - bool match(method, url, [data, headers, withCredentials]) { - if (method != method) return false; - if (!matchUrl(url)) return false; - if (data != null && !matchData(data)) return false; - if (headers != null && !matchHeaders(headers)) return false; - if (withCredentials != null && !matchWithCredentials(withCredentials)) return false; - return true; - } + bool match(req) => + matchMethodAndUrl(req) && matchData(req) && matchHeaders(req) && matchWithCredentials(req); + + bool matchMethodAndUrl(req) => + method == req.method && matchUrl(req); - bool matchUrl(u) { + bool matchUrl(req) { if (url == null) return true; - if (url is RegExp) return url.hasMatch(u); - return url == u; + return _matchUrl(url, req.url); } - bool matchHeaders(h) { + bool matchHeaders(req) { if (headers == null) return true; - if (headers is Function) return headers(h); - return "$headers" == "$h"; + if (headers is Function) return headers(req.headers); + return "$headers" == "${req.headers}"; } - bool matchData(d) { + bool matchData(req) { if (data == null) return true; - if (d == null) return false; - if (data is File) return data == d; - assert(d is String); - if (data is RegExp) return data.hasMatch(d); - return JSON.encode(data) == JSON.encode(d); + if (req.data == null) return false; + if (data is File) return data == req.data; + assert(req.data is String); + if (data is RegExp) return data.hasMatch(req.data); + return JSON.encode(data) == JSON.encode(req.data); } - bool matchWithCredentials(withCredentials) => this.withCredentials == withCredentials; + bool matchWithCredentials(req) { + if (withCredentials == null) return true; + if (req.withCredentials == null) return true; + return withCredentials == req.withCredentials; + } String toString() => "$method $url"; } @@ -117,13 +117,32 @@ class _Chain { respond([x,y,z]) => _respondFn(x,y,z); } + +/** + * An internal class used by [MockHttpBackend]. + */ +class RecordedRequest { + final String method; + final url, callback, data, headers, timeout; + final bool withCredentials; + + RecordedRequest({this.method, this.url, this.callback, this.data, + this.headers, this.timeout, this.withCredentials}); + + bool matchMethodAndUrl(method, url) => + this.method == method && _matchUrl(url, this.url); +} + +bool _matchUrl(expected, actual) => + (expected is RegExp) ? expected.hasMatch(actual) : expected == actual; + /** * A mock implementation of [HttpBackend], used in tests. */ class MockHttpBackend implements HttpBackend { var definitions = [], expectations = [], - responses = []; + requests = []; /** * This function is called from [Http] and designed to mimic the Dart APIs. @@ -164,81 +183,16 @@ class MockHttpBackend implements HttpBackend { } - /** - * A callback oriented API. This function takes a callback with - * will be called with (status, data, headers) - */ void call(method, url, callback, {data, headers, timeout, withCredentials: false}) { - var xhr = new _MockXhr(), - expectation = expectations.isEmpty ? null : expectations[0], - wasExpected = false; - - var prettyPrint = (data) { - return (data is String || data is Function || data is RegExp) - ? data - : JSON.encode(data); - }; - - var wrapResponse = (wrapped) { - var handleResponse = () { - var response = wrapped.response(method, url, data, headers); - xhr.respHeaders = response[2]; - utils.relaxFnApply(callback, [response[0], response[1], - xhr.getAllResponseHeaders()]); - }; - - var handleTimeout = () { - for (var i = 0; i < responses.length; i++) { - if (identical(responses[i], handleResponse)) { - responses.removeAt(i); - callback(-1, null, ''); - break; - } - } - }; - - if (timeout != null) timeout.then(handleTimeout); - return handleResponse; - }; - - if (expectation != null && expectation.match(method, url)) { - if (!expectation.matchData(data)) - throw ['Expected $expectation with different data\n' + - 'EXPECTED: ${prettyPrint(expectation.data)}\nGOT: $data']; - - if (!expectation.matchHeaders(headers)) - throw ['Expected $expectation with different headers\n' - 'EXPECTED: ${prettyPrint(expectation.headers)}\n' - 'GOT: ${prettyPrint(headers)}']; - - if (!expectation.matchWithCredentials(withCredentials)) - throw ['Expected $expectation with different withCredentials\n' - 'EXPECTED: ${prettyPrint(expectation.withCredentials)}\n' - 'GOT: ${prettyPrint(withCredentials)}']; - - expectations.removeAt(0); - - if (expectation.response != null) { - responses.add(wrapResponse(expectation)); - return; - } - wasExpected = true; - } - - for (var definition in definitions) { - if (definition.match(method, url, data, headers != null ? headers : {}, withCredentials)) { - if (definition.response != null) { - // if $browser specified, we do auto flush all requests - responses.add(wrapResponse(definition)); - } else throw ['No response defined !']; - return; - } - } - throw wasExpected ? - ['No response defined !'] : - ['Unexpected request: $method $url\n' + (expectation != null ? - 'Expected $expectation' : - 'No more requests expected')]; + requests.add(new RecordedRequest( + method: method, + url: url, + callback: callback, + data: data, + headers: headers, + timeout: timeout, + withCredentials: withCredentials + )); } /** @@ -475,19 +429,6 @@ class MockHttpBackend implements HttpBackend { _Chain expectPATCH(url, [data, headers, withCredentials = false]) => expect('PATCH', url, data, headers, withCredentials); - /** - * @ngdoc method - * @name ngMock.httpBackend#expectHEAD - * @methodOf ngMock.httpBackend - * @description - * Creates a new request expectation for HEAD requests. For more info see `expect()`. - * - * @param {string|RegExp} url HTTP url. - * @param {Object=} headers HTTP headers. - * @returns {requestHandler} Returns an object with `respond` method that control how a matched - * request is handled. - */ - /** * @ngdoc method * @name ngMock.httpBackend#flush @@ -500,21 +441,147 @@ class MockHttpBackend implements HttpBackend { * is called an exception is thrown (as this typically a sign of programming error). */ void flush([count]) { - if (responses.isEmpty) throw ['No pending request to flush !']; + if (requests.isEmpty) throw ['No pending request to flush !']; if (count != null) { while (count-- > 0) { - if (responses.isEmpty) throw ['No more pending request to flush !']; - responses.removeAt(0)(); + if (requests.isEmpty) throw ['No more pending request to flush !']; + _processRequest(requests.removeAt(0)); } } else { - while (!responses.isEmpty) { - responses.removeAt(0)(); + while (!requests.isEmpty) { + _processRequest(requests.removeAt(0)); } } verifyNoOutstandingExpectation(); } + /** + * Creates a new expectation and flushes all pending requests until the one matching the expectation. + * + * @param {string} method HTTP method. + * @param {string|RegExp} url HTTP url. + * @param {(string|RegExp)=} data HTTP request body. + * @param {(Object|function(Object))=} headers HTTP headers or function that + * receives http header object and returns true if the headers match the + * current definition. + * @returns {requestHandler} Returns an object with `respond` method that + * control how a matched request is handled. + * + * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` + * – The respond method takes a set of static data to be returned or a function that can return + * an array containing response status (number), response data (string) and response headers + * (Object). + */ + _Chain flushExpected(String method, url, [data, headers, withCredentials = false]) { + var expectation = new MockHttpExpectation(method, url, data, headers, withCredentials); + expectations.add(expectation); + + flushUntilMethodAndUrlMatch () { + while (requests.isNotEmpty) { + final r = requests.removeAt(0); + _processRequest(r); + if (r.matchMethodAndUrl(method, url)) return; + } + throw ['No more pending requests matching $method $url']; + } + + return new _Chain(respond: (status, data, headers) { + expectation.response = _createResponse(status, data, headers); + flushUntilMethodAndUrlMatch(); + }); + } + + /** + * @ngdoc method + * @name ngMock.httpBackend#flushGET + * @methodOf ngMock.httpBackend + * @description + * Creates a new request expectation for GET requests. For more info see `flushExpected()`. + * + * @param {string|RegExp} url HTTP url. + * @param {Object=} headers HTTP headers. + * @returns {requestHandler} Returns an object with `respond` method that control how a matched + * request is handled. + */ + _Chain flushGET(url, [headers, withCredentials = false]) => + flushExpected("GET", url, null, headers, withCredentials); + + /** + * @ngdoc method + * @name ngMock.httpBackend#flushPOST + * @methodOf ngMock.httpBackend + * @description + * Creates a new request expectation for POST requests. For more info see `flushExpected()`. + * + * @param {string|RegExp} url HTTP url. + * @param {Object=} headers HTTP headers. + * @returns {requestHandler} Returns an object with `respond` method that control how a matched + * request is handled. + */ + _Chain flushPOST(url, [data, headers, withCredentials = false]) => + flushExpected("POST", url, data, headers, withCredentials); + + /** + * @ngdoc method + * @name ngMock.httpBackend#flushPUT + * @methodOf ngMock.httpBackend + * @description + * Creates a new request expectation for PUT requests. For more info see `flushExpected()`. + * + * @param {string|RegExp} url HTTP url. + * @param {Object=} headers HTTP headers. + * @returns {requestHandler} Returns an object with `respond` method that control how a matched + * request is handled. + */ + _Chain flushPUT(url, [data, headers, withCredentials = false]) => + flushExpected("PUT", url, data, headers, withCredentials); + + /** + * @ngdoc method + * @name ngMock.httpBackend#flushPATCH + * @methodOf ngMock.httpBackend + * @description + * Creates a new request expectation for PATCH requests. For more info see `flushExpected()`. + * + * @param {string|RegExp} url HTTP url. + * @param {Object=} headers HTTP headers. + * @returns {requestHandler} Returns an object with `respond` method that control how a matched + * request is handled. + */ + _Chain flushPATCH(url, [data, headers, withCredentials = false]) => + flushExpected("PATCH", url, data, headers, withCredentials); + + /** + * @ngdoc method + * @name ngMock.httpBackend#flushDELETE + * @methodOf ngMock.httpBackend + * @description + * Creates a new request expectation for DELETE requests. For more info see `flushExpected()`. + * + * @param {string|RegExp} url HTTP url. + * @param {Object=} headers HTTP headers. + * @returns {requestHandler} Returns an object with `respond` method that control how a matched + * request is handled. + */ + _Chain flushDELETE(url, [headers, withCredentials = false]) => + flushExpected("DELETE", url, null, headers, withCredentials); + + /** + * @ngdoc method + * @name ngMock.httpBackend#flushJSONP + * @methodOf ngMock.httpBackend + * @description + * Creates a new request expectation for JSONP requests. For more info see `flushExpected()`. + * + * @param {string|RegExp} url HTTP url. + * @param {Object=} headers HTTP headers. + * @returns {requestHandler} Returns an object with `respond` method that control how a matched + * request is handled. + */ + _Chain flushJSONP(url, [headers, withCredentials = false]) => + flushExpected("JSONP", url, null, headers, withCredentials); + /** * @ngdoc method @@ -553,7 +620,7 @@ class MockHttpBackend implements HttpBackend { * */ void verifyNoOutstandingRequest() { - if (!responses.isEmpty) throw ['Unflushed requests: ${responses.length}']; + if (!requests.isEmpty) throw ['Unflushed requests: ${requests.length}']; } @@ -568,7 +635,96 @@ class MockHttpBackend implements HttpBackend { */ void resetExpectations() { expectations.length = 0; - responses.length = 0; + requests.length = 0; + } + + + void _processRequest(RecordedRequest req) { + prettyPrint(data) { + return (data is String || data is Function || data is RegExp) + ? data + : JSON.encode(data); + } + + handleResponse(expectation) { + final xhr = new _MockXhr(); + final response = expectation.response(req.method, req.url, req.data, req.headers); + + final status = response[0]; + final data = response[1]; + xhr.respHeaders = response[2]; + + utils.relaxFnApply(req.callback, [status, data, xhr.getAllResponseHeaders()]); + } + + handleResponseAndTimeout(expectation) { + if (req.timeout != null) { + req.timeout.then(() => req.callback(-1, null, '')); + } else { + handleResponse(expectation); + } + } + + checkExpectation(expectation) { + if (!expectation.matchData(req)) { + throw ['Expected $expectation with different data\n' + 'EXPECTED: ${prettyPrint(expectation.data)}\nGOT: ${req.data}']; + } + + if (!expectation.matchHeaders(req)) { + throw ['Expected $expectation with different headers\n' + 'EXPECTED: ${prettyPrint(expectation.headers)}\n' + 'GOT: ${prettyPrint(req.headers)}']; + } + + if (!expectation.matchWithCredentials(req)) { + throw ['Expected $expectation with different withCredentials\n' + 'EXPECTED: ${prettyPrint(expectation.withCredentials)}\n' + 'GOT: ${prettyPrint(req.withCredentials)}']; + } + } + + hasResponse(d) => d != null && d.response != null; + + executeFirstExpectation(e, d) { + checkExpectation(e); + expectations.remove(e); + + if (hasResponse(e)) { + handleResponseAndTimeout(e); + } else if (hasResponse(d)) { + handleResponseAndTimeout(d); + } else { + throw ['No response defined !']; + } + } + + executeMatchingDefinition(e, d) { + if (hasResponse(d)) { + handleResponseAndTimeout(d); + } else if (d != null) { + throw ['No response defined !'];; + } else if (e != null) { + throw ['Unexpected request: ${req.method} ${req.url}\nExpected $e']; + } else { + throw ['Unexpected request: ${req.method} ${req.url}\nNo more requests expected']; + } + } + + firstExpectation() => + expectations.isEmpty ? null : expectations.first; + + matchingDefinition() => + definitions.firstWhere((d) => d.match(req), orElse: () => null); + + final e = firstExpectation(); + final d = matchingDefinition(); + + if (e != null && e.matchMethodAndUrl(req)) { + executeFirstExpectation(e, d); + } else { + executeMatchingDefinition(e, d); + } } } diff --git a/test/core_dom/http_spec.dart b/test/core_dom/http_spec.dart index edcf2222b..89a7aa86e 100644 --- a/test/core_dom/http_spec.dart +++ b/test/core_dom/http_spec.dart @@ -70,16 +70,14 @@ void main() { it('should do basic request', async(() { - backend.expect('GET', '/url').respond(''); http(url: '/url', method: 'GET'); - flush(); + backend.flushGET('/url').respond(''); })); it('should pass data if specified', async(() { - backend.expect('POST', '/url', 'some-data').respond(''); http(url: '/url', method: 'POST', data: 'some-data'); - flush(); + backend.flushPOST('/url', 'some-data').respond(''); })); @@ -89,13 +87,15 @@ void main() { // we don't care about the data field. backend.expect('POST', '/url', 'null').respond(''); + expect(() { http(url: '/url', method: 'POST'); + backend.flush(); }).toThrow('with different data'); // satisfy the expectation for our afterEach's assert. http(url: '/url', method: 'POST', data: 'null'); - flush(); + backend.flush(); })); describe('backend', () { @@ -975,8 +975,9 @@ void main() { 'synchronous', async(() { backend.expect('POST', '/url', '').respond(''); http(url: '/url', method: 'POST', data: ''); + expect(interceptorCalled).toBe(true); - expect(backend.responses.isEmpty).toBe(false); // request made immediately + expect(backend.requests.isEmpty).toBe(false); // request made immediately flush(); })); }); diff --git a/test/mock/http_backend_spec.dart b/test/mock/http_backend_spec.dart index e8842f2b3..01df2982d 100644 --- a/test/mock/http_backend_spec.dart +++ b/test/mock/http_backend_spec.dart @@ -80,6 +80,7 @@ void main() { hb.when('GET', '/url1').respond(200, 'content'); expect(() { hb('GET', '/xxx', noop); + hb.flush(); }).toThrow('Unexpected request: GET /xxx\nNo more requests expected'); }); @@ -226,11 +227,10 @@ void main() { describe('expect()', () { it('should require specified order', () { - hb.expect('GET', '/url1').respond(200, ''); - hb.expect('GET', '/url2').respond(200, ''); - expect(() { hb('GET', '/url2', noop, headers: {}); + + hb.flushExpected('GET', '/url1').respond(200, ''); }).toThrow('Unexpected request: GET /url2\nExpected GET /url1'); }); @@ -256,6 +256,7 @@ void main() { expect(() { hb('GET', '/match', noop, headers: {}); + hb.flush(); }).toThrow('Expected GET /match with different headers\n' + 'EXPECTED: {"Content-Type":"application/json"}\nGOT: {}'); }); @@ -267,6 +268,7 @@ void main() { expect(() { hb('GET', '/match', noop, data: 'different'); + hb.flush(); }).toThrow('Expected GET /match with different data\n' + 'EXPECTED: some-data\nGOT: different'); }); @@ -318,7 +320,7 @@ void main() { hb.when('GET').respond(200, ''); hb('GET', '/url', callback); - expect(() {hb.flush(2);}).toThrow('No more pending request to flush !'); + expect(() {hb.flush(2); }).toThrow('No more pending request to flush !'); expect(callback).toHaveBeenCalledOnce(); }); @@ -352,6 +354,7 @@ void main() { }); hb('GET', '/url1', callback, timeout: new _Chain(then: then)); + hb.flush(); expect(canceler is Function).toBe(true); canceler(); // simulate promise resolution @@ -366,6 +369,7 @@ void main() { hb.when('GET', '/test'); expect(() { hb('GET', '/test', callback); + hb.flush(); }).toThrow('No response defined !'); }); @@ -374,10 +378,10 @@ void main() { hb.expect('GET', '/url'); expect(() { hb('GET', '/url', callback); + hb.flush(); }).toThrow('No response defined !'); }); - it('should respond undefined when JSONP method', () { hb.when('JSONP', '/url1').respond(200); hb.expect('JSONP', '/url2').respond(200); @@ -395,9 +399,11 @@ void main() { hb.expect('POST', '/u3').respond(201, '', {}); hb('POST', '/u1', noop, data: 'ddd', headers: {}); + expect(() {hb.flush(1); }).toThrow(); - expect(() {hb.verifyNoOutstandingExpectation();}). - toThrow('Unsatisfied requests: GET /u2, POST /u3'); + expect(() { + hb.verifyNoOutstandingExpectation(); + }).toThrow('Unsatisfied requests: GET /u2, POST /u3'); }); @@ -415,6 +421,7 @@ void main() { hb('GET', '/u2', noop); hb('POST', '/u3', noop); + hb.flush(); expect(() {hb.verifyNoOutstandingExpectation();}).not.toThrow(); }); @@ -501,24 +508,57 @@ void main() { }); + describe("flushExpected()", () { + it("flushes all the requests until a matching one is found", () { + hb.when("GET", '/first').respond("OK"); + + hb("GET", '/first', callback); + hb("GET", '/second', callback); + hb("GET", '/third', callback); + + hb.flushExpected("GET", "/second").respond("OK"); + + expect(hb.requests.length).toEqual(1); + expect(hb.requests.first.url).toEqual("/third"); + }); + }); + + + describe('flush shortcuts', () { + [[(x, r) => hb.flushGET(x).respond(r), 'GET'], + [(x, r) => hb.flushPOST(x).respond(r), 'POST'], + [(x, r) => hb.flushPUT(x).respond(r), 'PUT'], + [(x, r) => hb.flushPATCH(x).respond(r), 'PATCH'], + [(x, r) => hb.flushDELETE(x).respond(r), 'DELETE'], + [(x, r) => hb.flushJSONP(x).respond(r), 'JSONP'] + ].forEach((step) { + var shortcut = step[0], method = step[1]; + it('should provide $shortcut shortcut method', () { + hb(method, '/foo', callback); + shortcut('/foo', 'bar'); + expect(callback).toHaveBeenCalledOnceWith(200, 'bar', ''); + }); + }); + }); + describe('MockHttpExpectation', () { it('should accept url as regexp', () { var exp = new MockHttpExpectation('GET', new RegExp('^\/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); + expect(exp.match(new RecordedRequest(method: 'GET', url: '/x'))).toBe(true); + expect(exp.match(new RecordedRequest(method: 'GET', url: '/xxx/x'))).toBe(true); + expect(exp.match(new RecordedRequest(method: 'GET', url: 'x'))).toBe(false); + expect(exp.match(new RecordedRequest(method: 'GET', url: 'a/x'))).toBe(false); }); it('should accept data as regexp', () { var exp = new MockHttpExpectation('POST', '/url', new RegExp('\{.*?\}')); - 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); + expect(exp.match(new RecordedRequest(method: 'POST', url: '/url', data: '{"a": "aa"}'))).toBe(true); + expect(exp.match(new RecordedRequest(method: 'POST', url: '/url', data: '{"one": "two"}'))).toBe(true); + expect(exp.match(new RecordedRequest(method: 'POST', url: '/url', data: '{"one"'))).toBe(false); }); @@ -527,8 +567,9 @@ void main() { return h['Content-Type'] == 'application/json'; }); - expect(exp.matchHeaders({})).toBe(false); - expect(exp.matchHeaders({'Content-Type': 'application/json', 'X-Another': 'true'})).toBe(true); + expect(exp.matchHeaders(new RecordedRequest(headers: {}))).toBe(false); + expect(exp.matchHeaders(new RecordedRequest( + headers: {'Content-Type': 'application/json', 'X-Another': 'true'}))).toBe(true); }); }); });