add: adding client test helpers
This commit is contained in:
parent
ce6c4c56fa
commit
f384d2e3cd
6 changed files with 859 additions and 0 deletions
142
packages/testing/example/client_example.dart
Normal file
142
packages/testing/example/client_example.dart
Normal file
|
@ -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'}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
205
packages/testing/lib/src/client.dart
Normal file
205
packages/testing/lib/src/client.dart
Normal file
|
@ -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<String, String> _readHeaders = const {'Accept': 'application/json'};
|
||||||
|
const Map<String, String> _writeHeaders = const {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
final Uuid _uuid = Uuid();*/
|
||||||
|
|
||||||
|
/// Shorthand for bootstrapping a [TestClient].
|
||||||
|
Future<TestClient> 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<String, client.Service> _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<Cookie> 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<client.WebSockets> 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<StreamedResponse> 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 = <String, String>{};
|
||||||
|
|
||||||
|
rs.headers.forEach((k, v) {
|
||||||
|
extractedHeaders[k] = v.join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
Stream<List<int>> 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<String> 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<Id, Data> service<Id, Data>(String path,
|
||||||
|
{Type? type, client.AngelDeserializer<Data>? deserializer}) {
|
||||||
|
var uri = path.toString().replaceAll(_straySlashes, '');
|
||||||
|
return _services.putIfAbsent(uri,
|
||||||
|
() => _MockService<Id, Data>(this, uri, deserializer: deserializer))
|
||||||
|
as client.Service<Id, Data>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MockService<Id, Data> extends client.BaseAngelService<Id, Data> {
|
||||||
|
final TestClient _app;
|
||||||
|
|
||||||
|
_MockService(this._app, String basePath,
|
||||||
|
{client.AngelDeserializer<Data>? deserializer})
|
||||||
|
: super(_app, _app, basePath, deserializer: deserializer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StreamedResponse> 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<WebSocketChannel> getConnectedWebSocket() async {
|
||||||
|
var headers = <String, String>{};
|
||||||
|
|
||||||
|
if (app.authToken?.isNotEmpty == true) {
|
||||||
|
headers['authorization'] = 'Bearer ${app.authToken}';
|
||||||
|
}
|
||||||
|
|
||||||
|
var socket = await WebSocket.connect(baseUrl.toString(), headers: headers);
|
||||||
|
return IOWebSocketChannel(socket);
|
||||||
|
}
|
||||||
|
}
|
331
packages/testing/lib/src/matchers.dart
Normal file
331
packages/testing/lib/src/matchers.dart
Normal file
|
@ -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<String> 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<int>`, 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<int>) {
|
||||||
|
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 = <String>[];
|
||||||
|
|
||||||
|
_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<String> errors = [];
|
||||||
|
|
||||||
|
_IsAngelHttpException(
|
||||||
|
{this.message, this.statusCode, Iterable<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
packages/testing/lib/testing.dart
Normal file
2
packages/testing/lib/testing.dart
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export 'src/client.dart';
|
||||||
|
export 'src/matchers.dart';
|
|
@ -9,6 +9,15 @@ environment:
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
charcode: ^1.3.1
|
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:
|
dev_dependencies:
|
||||||
lints: ^4.0.0
|
lints: ^4.0.0
|
||||||
|
|
170
packages/testing/test/simple_test.dart
Normal file
170
packages/testing/test/simple_test.dart
Normal file
|
@ -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<String, Map<String, dynamic>>(
|
||||||
|
index: ([params]) async => [
|
||||||
|
<String, dynamic>{'michael': 'jackson'}
|
||||||
|
],
|
||||||
|
create: (data, [params]) async =>
|
||||||
|
<String, dynamic>{'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, [
|
||||||
|
<String, dynamic>{'michael': 'jackson'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index', () async {
|
||||||
|
var foo = client.service('foo');
|
||||||
|
var result = await foo.create({});
|
||||||
|
expect(result, <String, dynamic>{'foo': 'bar'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('websocket', () async {
|
||||||
|
var ws = await client.websocket();
|
||||||
|
var foo = ws.service('foo');
|
||||||
|
foo.create(<String, dynamic>{});
|
||||||
|
var result = await foo.onCreated.first;
|
||||||
|
expect(result is Map ? result : result.data,
|
||||||
|
equals(<String, dynamic>{'foo': 'bar'}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue