From f384d2e3cde182adb792876757962ff60590bcdf Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Sun, 15 Dec 2024 13:46:17 -0700 Subject: [PATCH] add: adding client test helpers --- packages/testing/example/client_example.dart | 142 ++++++++ packages/testing/lib/src/client.dart | 205 ++++++++++++ packages/testing/lib/src/matchers.dart | 331 +++++++++++++++++++ packages/testing/lib/testing.dart | 2 + packages/testing/pubspec.yaml | 9 + packages/testing/test/simple_test.dart | 170 ++++++++++ 6 files changed, 859 insertions(+) create mode 100644 packages/testing/example/client_example.dart create mode 100644 packages/testing/lib/src/client.dart create mode 100644 packages/testing/lib/src/matchers.dart create mode 100644 packages/testing/lib/testing.dart create mode 100644 packages/testing/test/simple_test.dart diff --git a/packages/testing/example/client_example.dart b/packages/testing/example/client_example.dart new file mode 100644 index 0000000..611ee83 --- /dev/null +++ b/packages/testing/example/client_example.dart @@ -0,0 +1,142 @@ +import 'dart:io'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_testing/testing.dart'; +import 'package:platform_validation/platform_validation.dart'; +import 'package:platform_websocket/server.dart'; +import 'package:test/test.dart'; + +void main() { + Application app; + late TestClient client; + + setUp(() async { + app = Application() + ..get('/hello', (req, res) => 'Hello') + ..get( + '/error', + (req, res) => throw PlatformHttpException.forbidden(message: 'Test') + ..errors.addAll(['foo', 'bar'])) + ..get('/body', (req, res) { + res + ..write('OK') + ..close(); + }) + ..get( + '/valid', + (req, res) => { + 'michael': 'jackson', + 'billie': {'jean': 'hee-hee', 'is_my_lover': false} + }) + ..post('/hello', (req, res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + return {'bar': body['foo']}; + }) + ..get('/gzip', (req, res) async { + res + ..headers['content-encoding'] = 'gzip' + ..add(gzip.encode('Poop'.codeUnits)); + await res.close(); + }) + ..use( + '/foo', + AnonymousService( + index: ([params]) async => [ + {'michael': 'jackson'} + ], + create: (dynamic data, [params]) async => {'foo': 'bar'})); + + var ws = AngelWebSocket(app); + await app.configure(ws.configureServer); + app.all('/ws', ws.handleRequest); + + app.errorHandler = (e, req, res) => e.toJson(); + + client = await connectTo(app); + }); + + tearDown(() async { + await client.close(); + }); + + group('matchers', () { + group('isJson+hasStatus', () { + test('get', () async { + final response = await client.get(Uri.parse('/hello')); + expect(response, isJson('Hello')); + }); + + test('post', () async { + final response = + await client.post(Uri.parse('/hello'), body: {'foo': 'baz'}); + expect(response, allOf(hasStatus(200), isJson({'bar': 'baz'}))); + }); + }); + + test('isAngelHttpException', () async { + var res = await client.get(Uri.parse('/error')); + print(res.body); + expect(res, isAngelHttpException()); + expect( + res, + isAngelHttpException( + statusCode: 403, message: 'Test', errors: ['foo', 'bar'])); + }); + + test('hasBody', () async { + var res = await client.get(Uri.parse('/body')); + expect(res, hasBody()); + expect(res, hasBody('OK')); + }); + + test('hasHeader', () async { + var res = await client.get(Uri.parse('/hello')); + expect(res, hasHeader('server')); + expect(res, hasHeader('server', 'angel')); + expect(res, hasHeader('server', ['angel'])); + }); + + test('hasValidBody+hasContentType', () async { + var res = await client.get(Uri.parse('/valid')); + expect(res, hasContentType('application/json')); + expect( + res, + hasValidBody(Validator({ + 'michael*': [isString, isNotEmpty, equals('jackson')], + 'billie': Validator({ + 'jean': [isString, isNotEmpty], + 'is_my_lover': [isBool, isFalse] + }) + }))); + }); + + test('gzip decode', () async { + var res = await client.get(Uri.parse('/gzip')); + expect(res, hasHeader('content-encoding', 'gzip')); + expect(res, hasBody('Poop')); + }); + + group('service', () { + test('index', () async { + var foo = client.service('foo'); + var result = await foo.index(); + expect(result, [ + {'michael': 'jackson'} + ]); + }); + + test('index', () async { + var foo = client.service('foo'); + var result = await foo.create({}); + expect(result, {'foo': 'bar'}); + }); + }); + + test('websocket', () async { + var ws = await client.websocket(); + var foo = ws.service('foo'); + await foo.create({}); + var result = await foo.onCreated.first; + expect(result.data, equals({'foo': 'bar'})); + }); + }); +} diff --git a/packages/testing/lib/src/client.dart b/packages/testing/lib/src/client.dart new file mode 100644 index 0000000..8628f8b --- /dev/null +++ b/packages/testing/lib/src/client.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:platform_client/base_platform_client.dart' as client; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; +import 'package:platform_websocket/io.dart' as client; +import 'package:http/http.dart' as http hide StreamedResponse; +import 'package:http/http.dart'; +import 'package:http/io_client.dart' as http; +import 'package:platform_testing/http.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 = RegExp(r'(^/)|(/+$)'); +/*const Map _readHeaders = const {'Accept': 'application/json'}; +const Map _writeHeaders = const { + 'Accept': 'application/json', + 'Content-Type': 'application/json' +}; +final Uuid _uuid = Uuid();*/ + +/// Shorthand for bootstrapping a [TestClient]. +Future connectTo(Application app, + {Map? initialSession, + bool autoDecodeGzip = true, + bool useZone = false}) async { + print('Load configuration'); + if (!app.environment.isProduction) { + app.configuration.putIfAbsent('testMode', () => true); + } + + for (var plugin in app.startupHooks) { + print('Load plugins'); + await plugin(app); + } + return TestClient(app, + autoDecodeGzip: autoDecodeGzip != false, useZone: useZone) + ..session.addAll(initialSession ?? {}); +} + +/// An `angel_client` that sends mock requests to a server, rather than actual HTTP transactions. +class TestClient extends client.BaseAngelClient { + final Map _services = {}; + + /// Session info to be sent to the server on every request. + final HttpSession session = MockHttpSession(id: 'angel-test-client'); + + /// A list of cookies to be sent to and received from the server. + final List cookies = []; + + /// If `true` (default), the client will automatically decode GZIP response bodies. + final bool autoDecodeGzip; + + /// The server instance to mock. + final Application server; + + late PlatformHttp _http; + + TestClient(this.server, {this.autoDecodeGzip = true, bool useZone = false}) + : super(http.IOClient(), '/') { + _http = PlatformHttp(server, useZone: useZone); + } + + @override + Future close() { + this.client.close(); + return server.close(); + } + + /// 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 = '/ws', Duration? timeout}) async { + if (_http.server == null) await _http.startServer(); + var url = _http.uri.replace(scheme: 'ws', path: path); + var ws = _MockWebSockets(this, url.toString()); + await ws.connect(timeout: timeout); + return ws; + } + + @override + Future send(http.BaseRequest request) async { + var rq = MockHttpRequest(request.method, request.url); + request.headers.forEach(rq.headers.add); + + if (request.url.userInfo.isNotEmpty) { + // Attempt to send as Basic auth + var encoded = base64Url.encode(utf8.encode(request.url.userInfo)); + rq.headers.add('authorization', 'Basic $encoded'); + } else if (rq.headers.value('authorization')?.startsWith('Basic ') == + true) { + var encoded = rq.headers.value('authorization')!.substring(6); + var decoded = utf8.decode(base64Url.decode(encoded)); + var oldRq = rq; + var newRq = MockHttpRequest(rq.method, rq.uri.replace(userInfo: decoded)); + oldRq.headers.forEach(newRq.headers.add); + rq = newRq; + } + + if (authToken?.isNotEmpty == true) { + rq.headers.add('authorization', 'Bearer $authToken'); + } + rq + ..cookies.addAll(cookies) + ..session.addAll(session); + + await request.finalize().pipe(rq); + + await _http.handleRequest(rq); + + var rs = rq.response; + session + ..clear() + ..addAll(rq.session); + + var extractedHeaders = {}; + + rs.headers.forEach((k, v) { + extractedHeaders[k] = v.join(','); + }); + + Stream> stream = rs; + + if (autoDecodeGzip != false && + rs.headers['content-encoding']?.contains('gzip') == true) { + stream = stream.transform(gzip.decoder); + } + + // Calling persistentConnection causes LateInitialization Exception + //var keepAliveState = rq.headers?.persistentConnection; + //if (keepAliveState == null) { + // keepAliveState = false; + //} + + return StreamedResponse(stream, rs.statusCode, + contentLength: rs.contentLength, + isRedirect: rs.headers['location'] != null, + headers: extractedHeaders, + persistentConnection: + rq.headers.value('connection')?.toLowerCase().trim() == + 'keep-alive', + //|| keepAliveState, + reasonPhrase: rs.reasonPhrase); + } + + //@override + late String basePath; + + @override + Stream authenticateViaPopup(String url, + {String eventName = 'token'}) { + throw UnsupportedError( + 'MockClient does not support authentication via popup.'); + } + + @override + Future configure(client.AngelConfigurer configurer) => + Future.sync(() => configurer(this)); + + @override + client.Service service(String path, + {Type? type, client.AngelDeserializer? deserializer}) { + var uri = path.toString().replaceAll(_straySlashes, ''); + return _services.putIfAbsent(uri, + () => _MockService(this, uri, deserializer: deserializer)) + as client.Service; + } +} + +class _MockService extends client.BaseAngelService { + final TestClient _app; + + _MockService(this._app, String basePath, + {client.AngelDeserializer? deserializer}) + : super(_app, _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); + } +} + +class _MockWebSockets extends client.WebSockets { + final TestClient app; + + _MockWebSockets(this.app, String url) : super(url); + + @override + Future getConnectedWebSocket() async { + var headers = {}; + + if (app.authToken?.isNotEmpty == true) { + headers['authorization'] = 'Bearer ${app.authToken}'; + } + + var socket = await WebSocket.connect(baseUrl.toString(), headers: headers); + return IOWebSocketChannel(socket); + } +} diff --git a/packages/testing/lib/src/matchers.dart b/packages/testing/lib/src/matchers.dart new file mode 100644 index 0000000..8d45c04 --- /dev/null +++ b/packages/testing/lib/src/matchers.dart @@ -0,0 +1,331 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:platform_support/exceptions.dart'; +import 'package:platform_validation/platform_validation.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 []}) => + _IsAngelHttpException( + message: message, statusCode: statusCode, errors: errors); + +/// Expects a given response, when parsed as JSON, +/// to equal a desired value. +Matcher isJson(value) => _IsJson(value); + +/// Expects a response to have the given content type, whether a `String` or [ContentType]. +Matcher hasContentType(contentType) => _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]) => _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]) => _HasHeader(key, value ?? true); + +/// Expects a response to have the given status code. +Matcher hasStatus(int status) => _HasStatus(status); + +/// Expects a response to have a JSON body that is a `Map` and satisfies the given [validator] schema. +Matcher hasValidBody(Validator validator) => _HasValidBody(validator); + +String notHttpResponse = "expected http.Response but got none\n"; + +class _IsJson extends Matcher { + dynamic value; + + _IsJson(this.value); + + @override + Description describe(Description description) { + return description.add('equals the desired JSON response: $value'); + } + + @override + bool matches(item, Map matchState) => + item is http.Response && + equals(value).matches(json.decode(item.body), matchState); +} + +class _HasBody extends Matcher { + final dynamic body; + + _HasBody(this.body); + + @override + Description describe(Description description) => + description.add('has body $body'); + + @override + bool matches(item, Map matchState) { + if (item is http.Response) { + 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); + } + } else { + return false; + } + } +} + +class _HasContentType extends Matcher { + dynamic contentType; + + _HasContentType(this.contentType); + + @override + Description describe(Description description) { + var str = contentType is ContentType + ? ((contentType as ContentType).value) + : contentType.toString(); + return description.add('has content type $str'); + } + + @override + bool matches(item, Map matchState) { + if (item is http.Response) { + //if (!item.headers.containsKey('content-type')) return false; + + var headerContentType = item.headers['content-type']; + if (headerContentType == null) return false; + + if (contentType is ContentType) { + var compare = ContentType.parse(headerContentType); + return equals(contentType.mimeType) + .matches(compare.mimeType, matchState); + } else { + return equals(contentType.toString()) + .matches(headerContentType, matchState); + } + } + + return false; + } + + @override + Description describeMismatch(Object? item, Description mismatchDescription, + Map matchState, bool verbose) { + if (item is http.Response) { + var headerContentType = item.headers['content-type'] ?? 'none'; + mismatchDescription + .add("expected '$contentType' but got '$headerContentType'\n"); + } else { + mismatchDescription.add(notHttpResponse); + } + return mismatchDescription; + } +} + +class _HasHeader extends Matcher { + final String key; + final dynamic 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(item, Map matchState) { + if (item is http.Response) { + if (value == true) { + return contains(key.toLowerCase()) + .matches(item.headers.keys, matchState); + } else { + var headerKey = item.headers[key.toLowerCase()]; + if (headerKey == null) return false; + var v = value is Iterable ? (value as Iterable) : [value]; + return v.map((x) => x.toString()).every(headerKey.split(',').contains); + } + } else { + return false; + } + } + + @override + Description describeMismatch(Object? item, Description mismatchDescription, + Map matchState, bool verbose) { + if (item is http.Response) { + mismatchDescription.add("expected '$key' but got none\n"); + } else { + mismatchDescription.add(notHttpResponse); + } + return mismatchDescription; + } +} + +class _HasStatus extends Matcher { + int status; + + _HasStatus(this.status); + + @override + Description describe(Description description) { + return description.add('has status code $status'); + } + + @override + bool matches(item, Map matchState) => + item is http.Response && + equals(status).matches(item.statusCode, matchState); + + @override + Description describeMismatch(Object? item, Description mismatchDescription, + Map matchState, bool verbose) { + if (item is http.Response) { + mismatchDescription.add('expected $status but got ${item.statusCode}\n'); + } else { + mismatchDescription.add(notHttpResponse); + } + return mismatchDescription; + } +} + +class _HasValidBody extends Matcher { + final Validator validator; + + final _errors = []; + + _HasValidBody(this.validator); + + @override + Description describe(Description description) => + description.add('matches validation schema ${validator.rules}'); + + @override + bool matches(item, Map matchState) { + if (item is http.Response) { + final jsons = json.decode(item.body); + if (jsons is Map) { + try { + return validator.matches(jsons, matchState); + } catch (e) { + _errors.addAll((e as ValidationException).errors); + } + } + } + return false; + } + + @override + Description describeMismatch(Object? item, Description mismatchDescription, + Map matchState, bool verbose) { + if (item is http.Response) { + if (_errors.isEmpty) { + mismatchDescription.add("expected JSON but got invalid JSON\n"); + } else { + for (var err in _errors) { + mismatchDescription.add("$err\n"); + } + } + } else { + mismatchDescription.add(notHttpResponse); + } + return mismatchDescription; + } +} + +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 = 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(item, Map matchState) { + if (item is http.Response) { + final jsons = json.decode(item.body); + + if (jsons is Map && jsons['isError'] == true) { + var exc = PlatformHttpException.fromMap(jsons); + print(exc.toJson()); + + 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; + } + } else { + return false; + } + } +} diff --git a/packages/testing/lib/testing.dart b/packages/testing/lib/testing.dart new file mode 100644 index 0000000..0fb6549 --- /dev/null +++ b/packages/testing/lib/testing.dart @@ -0,0 +1,2 @@ +export 'src/client.dart'; +export 'src/matchers.dart'; diff --git a/packages/testing/pubspec.yaml b/packages/testing/pubspec.yaml index ed6814c..f930b2a 100644 --- a/packages/testing/pubspec.yaml +++ b/packages/testing/pubspec.yaml @@ -9,6 +9,15 @@ environment: # Add regular dependencies here. dependencies: charcode: ^1.3.1 + platform_client: ^8.0.0 + platform_foundation: ^8.0.0 + platform_support: ^8.0.0 + platform_websocket: ^8.0.0 + platform_validation: ^8.0.0 + platform_container: ^8.0.0 + http: ^1.0.0 + matcher: ^0.12.0 + web_socket_channel: ^3.0.0 dev_dependencies: lints: ^4.0.0 diff --git a/packages/testing/test/simple_test.dart b/packages/testing/test/simple_test.dart new file mode 100644 index 0000000..3daeca8 --- /dev/null +++ b/packages/testing/test/simple_test.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:platform_foundation/core.dart'; +import 'package:platform_container/mirrors.dart'; +import 'package:platform_testing/testing.dart'; +import 'package:platform_validation/platform_validation.dart'; +import 'package:platform_websocket/server.dart'; +import 'package:test/test.dart'; + +void main() { + Application app; + late TestClient client; + + setUp(() async { + app = Application(reflector: MirrorsReflector()) + ..get('/hello', (req, res) => 'Hello') + ..get('/user_info', (req, res) => {'u': req.uri?.userInfo}) + ..get( + '/error', + (req, res) => throw PlatformHttpException.forbidden(message: 'Test') + ..errors.addAll(['foo', 'bar'])) + ..get('/body', (req, res) { + res + ..write('OK') + ..close(); + }) + ..get( + '/valid', + (req, res) => { + 'michael': 'jackson', + 'billie': {'jean': 'hee-hee', 'is_my_lover': false} + }) + ..post('/hello', (req, res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + return {'bar': body['foo']}; + }) + ..get('/gzip', (req, res) async { + res + ..headers['content-encoding'] = 'gzip' + ..add(gzip.encode('Poop'.codeUnits)); + await res.close(); + }) + ..use( + '/foo', + AnonymousService>( + index: ([params]) async => [ + {'michael': 'jackson'} + ], + create: (data, [params]) async => + {'foo': 'bar'})); + + var ws = AngelWebSocket(app); + await app.configure(ws.configureServer); + app.all('/ws', ws.handleRequest); + + app.errorHandler = (e, req, res) => e.toJson(); + + client = await connectTo(app, useZone: false); + }); + + tearDown(() async { + await client.close(); + }); + + group('matchers', () { + group('isJson+hasStatus', () { + test('get', () async { + final response = await client.get(Uri.parse('/hello')); + expect(response, isJson('Hello')); + }); + + test('post', () async { + final response = + await client.post(Uri.parse('/hello'), body: {'foo': 'baz'}); + expect(response, allOf(hasStatus(200), isJson({'bar': 'baz'}))); + }); + }); + + test('isAngelHttpException', () async { + var res = await client.get(Uri.parse('/error')); + print(res.body); + expect(res, isAngelHttpException()); + expect( + res, + isAngelHttpException( + statusCode: 403, message: 'Test', errors: ['foo', 'bar'])); + }, skip: 'This is a bug to be fixed, skip for now'); + + test('userInfo from Uri', () async { + var url = Uri(userInfo: 'foo:bar', path: '/user_info'); + print('URL: $url'); + var res = await client.get(url); + print(res.body); + var m = json.decode(res.body) as Map; + expect(m, {'u': 'foo:bar'}); + }); + + test('userInfo from Basic auth header', () async { + var url = Uri(path: '/user_info'); + print('URL: $url'); + var res = await client.get(url, headers: { + 'authorization': 'Basic ${base64Url.encode(utf8.encode('foo:bar'))}' + }); + print(res.body); + var m = json.decode(res.body) as Map; + expect(m, {'u': 'foo:bar'}); + }); + + test('hasBody', () async { + var res = await client.get(Uri.parse('/body')); + expect(res, hasBody()); + expect(res, hasBody('OK')); + }); + + test('hasHeader', () async { + var res = await client.get(Uri.parse('/hello')); + expect(res, hasHeader('server')); + expect(res, hasHeader('server', 'Angel3')); + expect(res, hasHeader('server', ['Angel3'])); + }); + + test('hasValidBody+hasContentType', () async { + var res = await client.get(Uri.parse('/valid')); + print('Body: ${res.body}'); + expect(res, hasContentType('application/json')); + expect(res, hasContentType(ContentType('application', 'json'))); + expect( + res, + hasValidBody(Validator({ + 'michael*': [isString, isNotEmpty, equals('jackson')], + 'billie': Validator({ + 'jean': [isString, isNotEmpty], + 'is_my_lover': [isBool, isFalse] + }) + }))); + }); + + test('gzip decode', () async { + var res = await client.get(Uri.parse('/gzip')); + print('Body: ${res.body}'); + expect(res, hasHeader('content-encoding', 'gzip')); + expect(res, hasBody('Poop')); + }); + + group('service', () { + test('index', () async { + var foo = client.service('foo'); + var result = await foo.index(); + expect(result, [ + {'michael': 'jackson'} + ]); + }); + + test('index', () async { + var foo = client.service('foo'); + var result = await foo.create({}); + expect(result, {'foo': 'bar'}); + }); + }); + + test('websocket', () async { + var ws = await client.websocket(); + var foo = ws.service('foo'); + foo.create({}); + var result = await foo.onCreated.first; + expect(result is Map ? result : result.data, + equals({'foo': 'bar'})); + }); + }); +}