diff --git a/README.md b/README.md index 63728e38..7e78be74 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,72 @@ # angel_test -[![version 1.0.1](https://img.shields.io/badge/pub-1.0.1-brightgreen.svg)](https://pub.dartlang.org/packages/angel_test) -[![build status](https://travis-ci.org/angel-dart/test.svg?branch=master)](https://travis-ci.org/angel-dart/test) +[![version 1.0.2](https://img.shields.io/badge/pub-1.0.2-brightgreen.svg)](https://pub.dartlang.org/packages/angel_test) +[![build status](https://travis-ci.org/angel-dart/test.svg)](https://travis-ci.org/angel-dart/test) Testing utility library for the Angel framework. -See the tests for examples. \ No newline at end of file +# TestClient +The `TestClient` class is a custom `angel_client` that sends mock requests to your server. +This means that you will not have to bind your server to HTTP to run. +Plus, it is an `angel_client`, and thus supports services and other goodies. + +The `TestClient` also supports WebSockets. WebSockets cannot be mocked (yet!) within this library, +so calling the `websocket()` function will also bind your server to HTTP, if it is not already listening. + +The return value is a `WebSockets` client instance +(from [`package:angel_websocket`](https://github.com/angel-dart/websocket)); + +```dart +var ws = await client.websocket('/ws'); +ws.service('api/users').onCreated.listen(...); + +// To receive all blobs of data sent on the WebSocket: +ws.onData.listen(...); +``` + +# Matchers +Several `Matcher`s are bundled with this package, and run on any `package:http` `Response`, +not just those returned by Angel. + +```dart +test('foo', () async { + var res = await client.get('/foo'); + expect(res, allOf([ + isJson({'foo': 'bar'}), + hasStatus(200), + hasContentType(ContentType.JSON), + hasContentType('application/json'), + hasHeader('server'), // Assert header present + hasHeader('server', 'angel'), // Assert header present with value + hasHeader('foo', ['bar', 'baz']), // ... Or multiple values + hasBody(), // Assert non-empty body + hasBody('{"foo":"bar"}') // Assert specific body + ])); +}); + +test('error', () async { + var res = await client.get('/error'); + expect(res, isAngelHttpException()); + expect(res, isAngelHttpException(statusCode: 404, message: ..., errors: [...])) // Optional +}); +``` + +`hasValidBody` is one of the most powerful `Matcher`s in this library, +because it allows you to validate a JSON body against a +[validation schema](https://github.com/angel-dart/validate). + +Angel provides a comprehensive validation library that integrates tightly +with the very `matcher` package that you already use for testing. :) + +[https://github.com/angel-dart/validate](https://github.com/angel-dart/validate) + +```dart +test('validate response', () async { + var res = await client.get('/bar'); + expect(res, hasValidBody(new Validator({ + 'foo': isBoolean, + 'bar': [isString, equals('baz')], + 'age*': [], + 'nested': someNestedValidator + }))); +}); +``` \ No newline at end of file diff --git a/lib/src/client.dart b/lib/src/client.dart index f229412f..a0023b6c 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1,104 +1,198 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:angel_client/angel_client.dart' show AngelAuthResult; +import 'package:angel_client/base_angel_client.dart' as client; import 'package:angel_client/io.dart' as client; import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_websocket/io.dart' as client; +import 'package:http/src/base_request.dart' as http; +import 'package:http/src/response.dart' as http; +import 'package:http/src/streamed_response.dart' as http; import 'package:mock_request/mock_request.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/io.dart'; import 'package:uuid/uuid.dart'; +final RegExp _straySlashes = new RegExp(r"(^/)|(/+$)"); +const Map _readHeaders = const {'Accept': 'application/json'}; +const Map _writeHeaders = const { + 'Accept': 'application/json', + 'Content-Type': 'application/json' +}; final Uuid _uuid = new Uuid(); -Future connectTo(Angel app, - {Map initialSession, bool saveSession: false}) async { - TestClient client; - var path = '/${_uuid.v1()}/${_uuid.v1()}/${_uuid.v1()}'; +/// Shorthand for bootstrapping a [TestClient]. +Future connectTo(Angel app, {Map initialSession}) async => + new TestClient(app)..session.addAll(initialSession ?? {}); - if (saveSession) { - app - ..get(path, (RequestContext req, res) async { - client._session = req.session; +/// An `angel_client` that sends mock requests to a server, rather than actual HTTP transactions. +class TestClient extends client.BaseAngelClient { + final Map _services = {}; - if (initialSession != null) { - req.session.addAll(initialSession); - } - }) - ..post(path, (RequestContext req, res) async { - client._session = req.session..addAll(req.body); - }) - ..patch(path, (RequestContext req, res) async { - req.body['keys'].forEach(req.session.remove); - client._session = req.session; - }); - } + /// Session info to be sent to the server on every request. + final HttpSession session = new MockHttpSession(id: 'angel-test-client'); - final server = await app.startServer(); - final url = 'http://${server.address.address}:${server.port}'; - client = new TestClient(server, url); + /// A list of cookies to be sent to and received from the server. + final List cookies = []; - if (saveSession) { - await client.client.get('$url$path'); - client._sessionPath = path; - } - - return client; -} - -Future mock(Angel app, String method, Uri uri, - {body, - Iterable cookies: const [], - Map headers: const {}}) async { - var rq = new MockHttpRequest(method, uri); - rq.cookies.addAll(cookies ?? []); - headers.forEach(rq.headers.add); - - if (body is! Map) { - rq.write(body); - } else if (rq.headers.contentType == null || - rq.headers.contentType.mimeType == ContentType.JSON.mimeType) { - rq - ..headers.contentType = ContentType.JSON - ..write(JSON.encode(body)); - } else if (rq.headers.contentType.mimeType == - 'application/x-www-form-urlencoded') { - rq - ..headers.contentType = - new ContentType('application', 'x-www-form-urlencoded') - ..write(body.keys.fold>( - [], - (out, k) => - out..add('$k=' + Uri.encodeComponent(body[k]))).join('&')); - } else - throw new UnsupportedError( - 'mock() only supports sending JSON or URL-encoded bodies.'); - - await rq.close(); - await app.handleRequest(rq); - return rq.response; -} - -/// Interacts with an Angel server. -class TestClient extends client.Rest { - final HttpServer server; - HttpSession _session; - String _sessionPath; - - /// Returns a pointer to the current session. - HttpSession get session => _session; - - TestClient(this.server, String path) : super(path); - - /// Adds data to the [session]. - Future addToSession(Map data) => post(_sessionPath, body: data); - - /// Removes data from the [session]. - Future removeFromSession(List keys) => patch(_sessionPath, - body: JSON.encode({'keys': keys}), - headers: {HttpHeaders.CONTENT_TYPE: ContentType.JSON.mimeType}); + /// The server instance to mock. + final Angel server; @override + String authToken; + + TestClient(this.server) : super(null, '/'); + Future close() async { - if (server != null) { - await server.close(force: true); + if (server.httpServer != null) await server.httpServer.close(force: true); + } + + /// Opens a WebSockets connection to the server. This will automatically bind the server + /// over HTTP, if it is not already listening. Unfortunately, WebSockets cannot be mocked (yet!). + Future websocket({String path, Duration timeout}) async { + HttpServer http = server.httpServer; + if (http == null) http = await server.startServer(); + var url = 'ws://${http.address.address}:${http.port}'; + var cleanPath = (path ?? '/ws')?.replaceAll(_straySlashes, ''); + if (cleanPath?.isNotEmpty == true) url += '/$cleanPath'; + var ws = new _MockWebSockets(this, url); + await ws.connect(timeout: timeout); + return ws; + } + + Future sendUnstreamed( + String method, url, Map headers, + [body, Encoding encoding]) => + send(method, url, headers, body, encoding).then(http.Response.fromStream); + + Future send( + String method, url, Map headers, + [body, Encoding encoding]) async { + var rq = new MockHttpRequest( + method, url is Uri ? url : Uri.parse(url.toString())); + headers?.forEach(rq.headers.add); + + if (authToken?.isNotEmpty == true) + rq.headers.set(HttpHeaders.AUTHORIZATION, 'Bearer $authToken'); + + rq..cookies.addAll(cookies)..session.addAll(session); + + if (body is Stream>) { + await rq.addStream(body); + } else if (body is List) { + rq.add(body); + } else if (body is Map) { + if (rq.headers.contentType == null || + rq.headers.contentType.mimeType == ContentType.JSON.mimeType) { + rq + ..headers.contentType = ContentType.JSON + ..write(JSON.encode( + body.keys.fold({}, (out, k) => out..[k.toString()] = body[k]))); + } else if (rq.headers.contentType?.mimeType == + 'application/x-www-form-urlencoded') { + rq.write(body.keys.fold>([], + (out, k) => out..add('$k=' + Uri.encodeComponent(body[k]))).join()); + } else { + throw new UnsupportedError( + 'Map bodies can only be sent for requests with the content type application/json or application/x-www-form-urlencoded.'); + } + } else if (body != null) { + rq.write(body); } + + await rq.close(); + await server.handleRequest(rq); + + var rs = rq.response; + session.addAll(rq.session); + + Map extractedHeaders = {}; + + rs.headers.forEach((k, v) { + extractedHeaders[k] = v.join(','); + }); + + return new http.StreamedResponse(rs, rs.statusCode, + contentLength: rs.contentLength, + isRedirect: rs.headers[HttpHeaders.LOCATION] != null, + headers: extractedHeaders, + persistentConnection: + rq.headers.value(HttpHeaders.CONNECTION)?.toLowerCase()?.trim() == + 'keep-alive', + reasonPhrase: rs.reasonPhrase); + } + + Future delete(url, {Map headers}) => + sendUnstreamed('DELETE', url, headers); + + Future get(url, {Map headers}) => + sendUnstreamed('GET', url, headers); + + Future head(url, {Map headers}) => + sendUnstreamed('HEAD', url, headers); + + Future patch(url, {body, Map headers}) => + sendUnstreamed('PATCH', url, headers, body); + + Future post(url, {body, Map headers}) => + sendUnstreamed('POST', url, headers, body); + + Future put(url, {body, Map headers}) => + sendUnstreamed('PUT', url, headers, body); + + @override + String basePath; + + @override + Stream authenticateViaPopup(String url, {String eventName: 'token'}) { + throw new UnsupportedError( + 'MockClient does not support authentication via popup.'); + } + + @override + Future configure(client.AngelConfigurer configurer) => configurer(this); + + @override + client.Service service(String path, + {Type type, client.AngelDeserializer deserializer}) { + String uri = path.toString().replaceAll(_straySlashes, ""); + return _services.putIfAbsent(uri, + new MockService(this, '$basePath/$uri', deserializer: deserializer)); + } +} + +class MockService extends client.BaseAngelService { + final TestClient _app; + + MockService(this._app, String basePath, + {client.AngelDeserializer deserializer}) + : super(null, _app, basePath, deserializer: deserializer); + + @override + Future send(http.BaseRequest request) { + if (app.authToken != null && app.authToken.isNotEmpty) { + request.headers['Authorization'] = 'Bearer ${app.authToken}'; + } + + return _app.send(request.method, request.url, request.headers, + request.finalize(), request); + } +} + +class _MockWebSockets extends client.WebSockets { + final TestClient app; + + _MockWebSockets(this.app, String url) : super(url); + + @override + Future getConnectedWebSocket() async { + Map headers = {}; + + if (app.authToken?.isNotEmpty == true) + headers[HttpHeaders.AUTHORIZATION] = 'Bearer ${app.authToken}'; + + var socket = await WebSocket.connect(basePath, headers: headers); + return new IOWebSocketChannel(socket); } } diff --git a/lib/src/matchers.dart b/lib/src/matchers.dart index 27c15522..b27931c1 100644 --- a/lib/src/matchers.dart +++ b/lib/src/matchers.dart @@ -1,14 +1,45 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/src/http/angel_http_exception.dart'; +import 'package:angel_validate/angel_validate.dart'; import 'package:http/http.dart' as http; import 'package:matcher/matcher.dart'; +/// Expects a response to be a JSON representation of an `AngelHttpException`. +/// +/// You can optionally check for a matching [message], [statusCode] and [errors]. +Matcher isAngelHttpException( + {String message, int statusCode, Iterable errors: const []}) => + new _IsAngelHttpException( + message: message, statusCode: statusCode, errors: errors); + /// Expects a given response, when parsed as JSON, /// to equal a desired value. Matcher isJson(value) => new _IsJson(value); +/// Expects a response to have the given content type, whether a `String` or [ContentType]. +Matcher hasContentType(contentType) => new _HasContentType(contentType); + +/// Expects a response to have the given body. +/// +/// If `true` is passed as the value (default), then this matcher will simply assert +/// that the response has a non-empty body. +/// +/// If value is a `List`, then it will be matched against `res.bodyBytes`. +/// Otherwise, the string value will be matched against `res.body`. +Matcher hasBody([value]) => new _HasBody(value ?? true); + +/// Expects a response to have a header named [key] which contains [value]. [value] can be a `String`, or a List of `String`s. +/// +/// If `value` is true (default), then this matcher will simply assert that the header is present. +Matcher hasHeader(String key, [value]) => new _HasHeader(key, value ?? true); + /// Expects a response to have the given status code. Matcher hasStatus(int status) => new _HasStatus(status); +/// Expects a response to have a JSON body that is a `Map` and satisfies the given [validator] schema. +Matcher hasValidBody(Validator validator) => new _HasValidBody(validator); + class _IsJson extends Matcher { var value; @@ -16,7 +47,7 @@ class _IsJson extends Matcher { @override Description describe(Description description) { - return description.add('should equal the desired JSON response: $value'); + return description.add('equals the desired JSON response: $value'); } @override @@ -24,6 +55,79 @@ class _IsJson extends Matcher { equals(value).matches(JSON.decode(item.body), matchState); } +class _HasBody extends Matcher { + final body; + + _HasBody(this.body); + + @override + Description describe(Description description) => + description.add('has body $body'); + + @override + bool matches(http.Response item, Map matchState) { + if (body == true) return isNotEmpty.matches(item.bodyBytes, matchState); + if (body is List) + return equals(body).matches(item.bodyBytes, matchState); + else + return equals(body.toString()).matches(item.body, matchState); + } +} + +class _HasContentType extends Matcher { + var contentType; + + _HasContentType(this.contentType); + + @override + Description describe(Description description) { + var str = + contentType is ContentType ? contentType.value : contentType.toString(); + return description.add('has content type ' + str); + } + + @override + bool matches(http.Response item, Map matchState) { + if (!item.headers.containsKey(HttpHeaders.CONTENT_TYPE)) return false; + + if (contentType is ContentType) { + var compare = ContentType.parse(item.headers[HttpHeaders.CONTENT_TYPE]); + return equals(contentType.mimeType).matches(compare.mimeType, matchState); + } else { + return equals(contentType.toString()) + .matches(item.headers[HttpHeaders.CONTENT_TYPE], matchState); + } + } +} + +class _HasHeader extends Matcher { + final String key; + final value; + + _HasHeader(this.key, this.value); + + @override + Description describe(Description description) { + if (value == true) + return description.add('contains header $key'); + else + return description.add('contains header $key with value(s) $value'); + } + + @override + bool matches(http.Response item, Map matchState) { + if (value == true) { + return contains(key.toLowerCase()).matches(item.headers.keys, matchState); + } else { + if (!item.headers.containsKey(key.toLowerCase())) return false; + Iterable v = value is Iterable ? value : [value]; + return v + .map((x) => x.toString()) + .every(item.headers[key.toLowerCase()].split(',').contains); + } + } +} + class _HasStatus extends Matcher { int status; @@ -31,10 +135,93 @@ class _HasStatus extends Matcher { @override Description describe(Description description) { - return description.add('should have status code $status'); + return description.add('has status code $status'); } @override bool matches(http.Response item, Map matchState) => equals(status).matches(item.statusCode, matchState); } + +class _HasValidBody extends Matcher { + final Validator validator; + + _HasValidBody(this.validator); + + @override + Description describe(Description description) => + description.add('matches validation schema ${validator.rules}'); + + @override + bool matches(http.Response item, Map matchState) { + final json = JSON.decode(item.body); + if (json is! Map) return false; + return validator.matches(json, matchState); + } +} + +class _IsAngelHttpException extends Matcher { + String message; + int statusCode; + final List errors = []; + + _IsAngelHttpException( + {this.message, this.statusCode, Iterable errors: const []}) { + this.errors.addAll(errors ?? []); + } + + @override + Description describe(Description description) { + if (message?.isNotEmpty != true && statusCode == null && errors.isEmpty) + return description.add('is an Angel HTTP Exception'); + else { + var buf = new StringBuffer('is an Angel HTTP Exception with'); + + if (statusCode != null) buf.write(' status code $statusCode'); + + if (message?.isNotEmpty == true) { + if (statusCode != null && errors.isNotEmpty) + buf.write(','); + else if (statusCode != null && errors.isEmpty) buf.write(' and'); + buf.write(' message "$message"'); + } + + if (errors.isNotEmpty) { + if (statusCode != null || message?.isNotEmpty == true) + buf.write(' and errors $errors'); + else + buf.write(' errors $errors'); + } + + return description.add(buf.toString()); + } + } + + @override + bool matches(http.Response item, Map matchState) { + final json = JSON.decode(item.body); + + if (json is Map && json['isError'] == true) { + var exc = new AngelHttpException.fromMap(json); + + if (message?.isNotEmpty != true && statusCode == null && errors.isEmpty) + return true; + else { + if (statusCode != null) if (!equals(statusCode) + .matches(exc.statusCode, matchState)) return false; + + if (message?.isNotEmpty == true) if (!equals(message) + .matches(exc.message, matchState)) return false; + + if (errors.isNotEmpty) { + if (!errors + .every((err) => contains(err).matches(exc.errors, matchState))) + return false; + } + + return true; + } + } else + return false; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6a1633ac..951b14ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,10 +2,12 @@ author: "Tobe O " description: "Testing utility library for the Angel framework." homepage: "https://github.com/angel-dart/test.git" name: "angel_test" -version: "1.0.1" +version: "1.0.2" dependencies: - angel_client: "^1.0.0-dev+16" + angel_client: "^1.0.0" angel_framework: "^1.0.0-dev" + angel_validate: ^1.0.0 + angel_websocket: ^1.0.0 http: "^0.11.3+9" matcher: "^0.12.0+2" mock_request: ^1.0.0 diff --git a/test/simple_test.dart b/test/simple_test.dart index d378ddc5..1ad1f78a 100644 --- a/test/simple_test.dart +++ b/test/simple_test.dart @@ -1,67 +1,105 @@ -import 'dart:convert'; -import 'package:angel_framework/angel_framework.dart' as server; +import 'package:angel_framework/angel_framework.dart'; import 'package:angel_test/angel_test.dart'; +import 'package:angel_validate/angel_validate.dart'; +import 'package:angel_websocket/server.dart'; import 'package:test/test.dart'; main() { - server.Angel app; - TestClient testClient; + Angel app; + TestClient client; setUp(() async { - app = new server.Angel() + app = new Angel() ..get('/hello', 'Hello') + ..get( + '/error', + () => throw new AngelHttpException.forbidden(message: 'Test') + ..errors.addAll(['foo', 'bar'])) + ..get('/body', (ResponseContext res) { + res + ..write('OK') + ..end(); + }) + ..get( + '/valid', + () => { + 'michael': 'jackson', + 'billie': {'jean': 'hee-hee', 'is_my_lover': false} + }) ..post('/hello', (req, res) async { return {'bar': req.body['foo']}; - }); + }) + ..use( + '/foo', + new AnonymousService( + create: (data, [params]) async => {'foo': 'bar'})); - testClient = await connectTo(app); + var ws = new AngelWebSocket(); + await app.configure(ws); + + client = await connectTo(app); }); tearDown(() async { - await testClient.close(); + await client.close(); app = null; }); - test('mock()', () async { - var response = await mock(app, 'GET', Uri.parse('/hello')); - expect(await response.transform(UTF8.decoder).join(), equals('"Hello"')); - }); + group('matchers', () { + group('isJson+hasStatus', () { + test('get', () async { + final response = await client.get('/hello'); + expect(response, isJson('Hello')); + }); - group('isJson+hasStatus', () { - test('get', () async { - final response = await testClient.get('/hello'); - expect(response, isJson('Hello')); + test('post', () async { + final response = await client.post('/hello', body: {'foo': 'baz'}); + expect(response, allOf(hasStatus(200), isJson({'bar': 'baz'}))); + }); }); - test('post', () async { - final response = await testClient.post('/hello', body: {'foo': 'baz'}); - expect(response, allOf(hasStatus(200), isJson({'bar': 'baz'}))); - }); - }); - - group('session', () { - test('initial session', () async { - final TestClient client = await connectTo(app, - initialSession: {'foo': 'bar'}, saveSession: true); - expect(client.session['foo'], equals('bar')); + test('isAngelHttpException', () async { + var res = await client.get('/error'); + expect(res, isAngelHttpException()); + expect( + res, + isAngelHttpException( + statusCode: 403, message: 'Test', errors: ['foo', 'bar'])); }); - test('add to session', () async { - final TestClient client = await connectTo(app, saveSession: true); - await client.addToSession({'michael': 'jackson'}); - expect(client.session['michael'], equals('jackson')); + test('hasBody', () async { + var res = await client.get('/body'); + expect(res, hasBody()); + expect(res, hasBody('OK')); }); - test('remove from session', () async { - final TestClient client = await connectTo(app, - initialSession: {'angel': 'framework'}, saveSession: true); - await client.removeFromSession(['angel']); - expect(client.session.containsKey('angel'), isFalse); + test('hasHeader', () async { + var res = await client.get('/hello'); + expect(res, hasHeader('server')); + expect(res, hasHeader('server', 'angel')); + expect(res, hasHeader('server', ['angel'])); }); - test('disable session', () async { - final client = await connectTo(app, saveSession: false); - expect(client.session, isNull); + test('hasValidBody', () async { + var res = await client.get('/valid'); + expect(res, hasContentType('application/json')); + expect( + res, + hasValidBody(new Validator({ + 'michael*': [isString, isNotEmpty, equals('jackson')], + 'billie': new Validator({ + 'jean': [isString, isNotEmpty], + 'is_my_lover': [isBool, isFalse] + }) + }))); + }); + + test('websocket', () async { + var ws = await client.websocket(); + var foo = ws.service('foo'); + foo.create({}); + var result = await foo.onCreated.first; + expect(result.data, equals({'foo': 'bar'})); }); }); }