This commit is contained in:
Tobe O 2018-06-28 12:34:05 -04:00
parent 0ca81479ff
commit eb38637a6b
12 changed files with 312 additions and 62 deletions

View file

@ -1,5 +1,7 @@
# 1.0.5 # 1.0.5-beta
* Use `wrapMatcher` on explicit values instead of throwing. * Use `wrapMatcher` on explicit values instead of throwing.
* Add async matchers.
* Add context-aware matchers.
# 1.0.4 # 1.0.4
* `isNonEmptyString` trims strings. * `isNonEmptyString` trims strings.

View file

@ -2,6 +2,7 @@
library angel_validate; library angel_validate;
export 'package:matcher/matcher.dart'; export 'package:matcher/matcher.dart';
export 'src/context_aware.dart';
export 'src/matchers.dart'; export 'src/matchers.dart';
export 'src/validator.dart'; export 'src/validator.dart';

View file

@ -1,6 +1,8 @@
/// Support for using `angel_validate` with the Angel Framework. /// Support for using `angel_validate` with the Angel Framework.
library angel_validate.server; library angel_validate.server;
import 'dart:async';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'src/async.dart'; import 'src/async.dart';
import 'angel_validate.dart'; import 'angel_validate.dart';
@ -50,7 +52,8 @@ RequestMiddleware filterQuery(Iterable<String> only) {
RequestMiddleware validate(Validator validator, RequestMiddleware validate(Validator validator,
{String errorMessage: 'Invalid data.'}) { {String errorMessage: 'Invalid data.'}) {
return (RequestContext req, res) async { return (RequestContext req, res) async {
var result = validator.check(await req.lazyBody()); var result =
await asyncApplyValidator(validator, await req.lazyBody(), req.app);
if (result.errors.isNotEmpty) { if (result.errors.isNotEmpty) {
throw new AngelHttpException.badRequest( throw new AngelHttpException.badRequest(
@ -70,7 +73,7 @@ RequestMiddleware validate(Validator validator,
RequestMiddleware validateQuery(Validator validator, RequestMiddleware validateQuery(Validator validator,
{String errorMessage: 'Invalid data.'}) { {String errorMessage: 'Invalid data.'}) {
return (RequestContext req, res) async { return (RequestContext req, res) async {
var result = validator.check(req.query); var result = await asyncApplyValidator(validator, req.query, req.app);
if (result.errors.isNotEmpty) { if (result.errors.isNotEmpty) {
throw new AngelHttpException.badRequest( throw new AngelHttpException.badRequest(
@ -89,8 +92,9 @@ RequestMiddleware validateQuery(Validator validator,
/// filtered data before continuing the service event. /// filtered data before continuing the service event.
HookedServiceEventListener validateEvent(Validator validator, HookedServiceEventListener validateEvent(Validator validator,
{String errorMessage: 'Invalid data.'}) { {String errorMessage: 'Invalid data.'}) {
return (HookedServiceEvent e) { return (HookedServiceEvent e) async {
var result = validator.check(e.data as Map); var result = await asyncApplyValidator(
validator, e.data as Map, (e.request?.app ?? e.service.app) as Angel);
if (result.errors.isNotEmpty) { if (result.errors.isNotEmpty) {
throw new AngelHttpException.badRequest( throw new AngelHttpException.badRequest(
@ -102,3 +106,36 @@ HookedServiceEventListener validateEvent(Validator validator,
..addAll(result.data); ..addAll(result.data);
}; };
} }
/// Asynchronously apply a [validator], running any [AngelMatcher]s.
Future<ValidationResult> asyncApplyValidator(
Validator validator, Map data, Angel app) async {
var result = validator.check(data);
if (result.errors.isNotEmpty) return result;
var errantKeys = <String>[], errors = <String>[];
for (var key in result.data.keys) {
var value = result.data[key];
var description = new StringDescription("'$key': expected ");
for (var rule in validator.rules[key]) {
if (rule is AngelMatcher) {
var r = await rule.matchesWithAngel(value, key, result.data, {}, app);
if (!r) {
errors.add(rule.describe(description).toString().trim());
errantKeys.add(key);
break;
}
}
}
}
var m = new Map<String, dynamic>.from(result.data);
for (var key in errantKeys) {
m.remove(key);
}
return result.withData(m).withErrors(errors);
}

View file

@ -2,16 +2,14 @@ import 'dart:async';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:angel_http_exception/angel_http_exception.dart'; import 'package:angel_http_exception/angel_http_exception.dart';
import 'package:matcher/matcher.dart'; import 'package:matcher/matcher.dart';
import 'context_aware.dart';
/// Returns an [AngelMatcher] that uses an arbitrary function that returns /// Returns an [AngelMatcher] that uses an arbitrary function that returns
/// true or false for the actual value. /// true or false for the actual value.
/// ///
/// Analogous to the synchronous [predicate] matcher. /// Analogous to the synchronous [predicate] matcher.
/// AngelMatcher predicateWithAngel(
/// For example: FutureOr<bool> Function(String, Object, Angel) f,
///
/// expect(v, predicate((x) => ((x % 2) == 0), "is even"))
AngelMatcher predicateWithAngel(FutureOr<bool> Function(Object, Angel) f,
[String description = 'satisfies function']) => [String description = 'satisfies function']) =>
new _PredicateWithAngel(f, description); new _PredicateWithAngel(f, description);
@ -19,26 +17,38 @@ AngelMatcher predicateWithAngel(FutureOr<bool> Function(Object, Angel) f,
/// to the input. /// to the input.
/// ///
/// Use this to match values against configuration, injections, etc. /// Use this to match values against configuration, injections, etc.
AngelMatcher matchWithAngel(FutureOr<Matcher> Function(Object, Angel) f, AngelMatcher matchWithAngel(FutureOr<Matcher> Function(Object, Map, Angel) f,
[String description = 'satisfies asynchronously created matcher']) => [String description = 'satisfies asynchronously created matcher']) =>
new _MatchWithAngel(f, description); new _MatchWithAngel(f, description);
/// Calls [matchWithAngel] without the initial parameter. /// Calls [matchWithAngel] without the initial parameter.
AngelMatcher matchWithAngelBinary(
FutureOr<Matcher> Function(Map context, Angel) f,
[String description = 'satisfies asynchronously created matcher']) =>
matchWithAngel((_, context, app) => f(context, app));
/// Calls [matchWithAngel] without the initial two parameters.
AngelMatcher matchWithAngelUnary(FutureOr<Matcher> Function(Angel) f, AngelMatcher matchWithAngelUnary(FutureOr<Matcher> Function(Angel) f,
[String description = 'satisfies asynchronously created matcher']) => [String description = 'satisfies asynchronously created matcher']) =>
matchWithAngel((_, app) => f(app)); matchWithAngelBinary((_, app) => f(app));
/// Calls [matchWithAngel] without any parameters.
AngelMatcher matchWithAngelNullary(FutureOr<Matcher> Function() f,
[String description = 'satisfies asynchronously created matcher']) =>
matchWithAngelUnary((_) => f());
/// Returns an [AngelMatcher] that represents [x]. /// Returns an [AngelMatcher] that represents [x].
/// ///
/// If [x] is an [AngelMatcher], then it is returned, unmodified. /// If [x] is an [AngelMatcher], then it is returned, unmodified.
AngelMatcher wrapAngelMatcher(x) { AngelMatcher wrapAngelMatcher(x) {
if (x is AngelMatcher) return x; if (x is AngelMatcher) return x;
return matchWithAngel((_, app) => wrapMatcher(x)); if (x is ContextAwareMatcher) return new _WrappedAngelMatcher(x);
return wrapAngelMatcher(wrapContextAwareMatcher(x));
} }
/// Returns an [AngelMatcher] that asynchronously resolves a [feature], builds a [matcher], and executes it. /// Returns an [AngelMatcher] that asynchronously resolves a [feature], builds a [matcher], and executes it.
AngelMatcher matchAsync( AngelMatcher matchAsync(FutureOr<Matcher> Function(String, Object) matcher,
FutureOr<Matcher> Function(Object) matcher, FutureOr Function() feature, FutureOr Function() feature,
[String description = 'satisfies asynchronously created matcher']) { [String description = 'satisfies asynchronously created matcher']) {
return new _MatchAsync(matcher, feature, description); return new _MatchAsync(matcher, feature, description);
} }
@ -48,7 +58,7 @@ AngelMatcher matchAsync(
AngelMatcher idExistsInService(String servicePath, AngelMatcher idExistsInService(String servicePath,
{String idField: 'id', String description}) { {String idField: 'id', String description}) {
return predicateWithAngel( return predicateWithAngel(
(item, app) async { (key, item, app) async {
try { try {
var result = await app.service(servicePath)?.read(item); var result = await app.service(servicePath)?.read(item);
return result != null; return result != null;
@ -65,17 +75,34 @@ AngelMatcher idExistsInService(String servicePath,
} }
/// An asynchronous [Matcher] that runs in the context of an [Angel] app. /// An asynchronous [Matcher] that runs in the context of an [Angel] app.
abstract class AngelMatcher extends Matcher { abstract class AngelMatcher extends ContextAwareMatcher {
Future<bool> matchesAsync(item, Map matchState, Angel app); Future<bool> matchesWithAngel(
item, String key, Map context, Map matchState, Angel app);
@override @override
bool matches(item, Map matchState) { bool matchesWithContext(item, String key, Map context, Map matchState) {
return true; return true;
} }
} }
class _WrappedAngelMatcher extends AngelMatcher {
final ContextAwareMatcher matcher;
_WrappedAngelMatcher(this.matcher);
@override
Description describe(Description description) =>
matcher.describe(description);
@override
Future<bool> matchesWithAngel(
item, String key, Map context, Map matchState, Angel app) async {
return matcher.matchesWithContext(item, key, context, matchState);
}
}
class _MatchWithAngel extends AngelMatcher { class _MatchWithAngel extends AngelMatcher {
final FutureOr<Matcher> Function(Object, Angel) f; final FutureOr<Matcher> Function(Object, Map, Angel) f;
final String description; final String description;
_MatchWithAngel(this.f, this.description); _MatchWithAngel(this.f, this.description);
@ -86,15 +113,16 @@ class _MatchWithAngel extends AngelMatcher {
: description.add(this.description); : description.add(this.description);
@override @override
Future<bool> matchesAsync(item, Map matchState, Angel app) { Future<bool> matchesWithAngel(
return new Future.sync(() => f(item, app)).then((result) { item, String key, Map context, Map matchState, Angel app) {
return new Future.sync(() => f(item, context, app)).then((result) {
return result.matches(item, matchState); return result.matches(item, matchState);
}); });
} }
} }
class _PredicateWithAngel extends AngelMatcher { class _PredicateWithAngel extends AngelMatcher {
final FutureOr<bool> Function(Object, Angel) predicate; final FutureOr<bool> Function(String, Object, Angel) predicate;
final String description; final String description;
_PredicateWithAngel(this.predicate, this.description); _PredicateWithAngel(this.predicate, this.description);
@ -105,13 +133,14 @@ class _PredicateWithAngel extends AngelMatcher {
: description.add(this.description); : description.add(this.description);
@override @override
Future<bool> matchesAsync(item, Map matchState, Angel app) { Future<bool> matchesWithAngel(
return new Future<bool>.sync(() => predicate(item, app)); item, String key, Map context, Map matchState, Angel app) {
return new Future<bool>.sync(() => predicate(key, item, app));
} }
} }
class _MatchAsync extends AngelMatcher { class _MatchAsync extends AngelMatcher {
final FutureOr<Matcher> Function(Object) matcher; final FutureOr<Matcher> Function(String, Object) matcher;
final FutureOr Function() feature; final FutureOr Function() feature;
final String description; final String description;
@ -123,9 +152,11 @@ class _MatchAsync extends AngelMatcher {
: description.add(this.description); : description.add(this.description);
@override @override
Future<bool> matchesAsync(item, Map matchState, Angel app) async { Future<bool> matchesWithAngel(
item, String key, Map context, Map matchState, Angel app) async {
var f = await feature(); var f = await feature();
var m = await matcher(f); var m = await matcher(key, f);
return m.matches(item, matchState); var c = wrapAngelMatcher(m);
return await c.matchesWithAngel(item, key, context, matchState, app);
} }
} }

View file

@ -0,0 +1,53 @@
import 'package:matcher/matcher.dart';
/// Returns a [ContextAwareMatcher] for the given predicate.
ContextAwareMatcher predicateWithContext(
bool Function(Object, String, Map, Map) f,
[String description = 'satisfies function']) {
return new _PredicateWithContext(f, description);
}
/// Wraps [x] in a [ContextAwareMatcher].
ContextAwareMatcher wrapContextAwareMatcher(x) {
if (x is ContextAwareMatcher)
return x;
else if (x is Matcher) return new _WrappedContextAwareMatcher(x);
return wrapContextAwareMatcher(wrapMatcher(x));
}
/// A special [Matcher] that is aware of the context in which it is being executed.
abstract class ContextAwareMatcher extends Matcher {
bool matchesWithContext(item, String key, Map context, Map matchState);
@override
bool matches(item, Map matchState) => true;
}
class _WrappedContextAwareMatcher extends ContextAwareMatcher {
final Matcher matcher;
_WrappedContextAwareMatcher(this.matcher);
@override
Description describe(Description description) =>
matcher.describe(description);
@override
bool matchesWithContext(item, String key, Map context, Map matchState) =>
matcher.matches(item, matchState);
}
class _PredicateWithContext extends ContextAwareMatcher {
final bool Function(Object, String, Map, Map) f;
final String desc;
_PredicateWithContext(this.f, this.desc);
@override
Description describe(Description description) =>
desc == null ? description : description.add(desc);
@override
bool matchesWithContext(item, String key, Map context, Map matchState) =>
f(item, key, context, matchState);
}

View file

@ -0,0 +1,16 @@
import 'package:matcher/matcher.dart';
/// A [Matcher] directly invoked by `package:angel_serialize` to validate the context.
class ContextValidator extends Matcher {
final bool Function(String, Map) validate;
final Description Function(Description, String, Map) errorMessage;
ContextValidator(this.validate, this.errorMessage);
@override
Description describe(Description description) => description;
@override
bool matches(item, Map matchState) => true;
}

View file

@ -1,4 +1,6 @@
import 'package:matcher/matcher.dart'; import 'package:matcher/matcher.dart';
import 'context_aware.dart';
import 'context_validator.dart';
final RegExp _alphaDash = new RegExp(r'^[A-Za-z0-9_-]+$'); final RegExp _alphaDash = new RegExp(r'^[A-Za-z0-9_-]+$');
final RegExp _alphaNum = new RegExp(r'^[A-Za-z0-9]+$'); final RegExp _alphaNum = new RegExp(r'^[A-Za-z0-9]+$');
@ -13,6 +15,7 @@ final Matcher isAlphaDash = predicate(
'alphanumeric (dashes and underscores are allowed)'); 'alphanumeric (dashes and underscores are allowed)');
/// Asserts that a `String` is alphanumeric, but also lets it contain dashes or underscores. /// Asserts that a `String` is alphanumeric, but also lets it contain dashes or underscores.
///
final Matcher isAlphaNum = predicate( final Matcher isAlphaNum = predicate(
(value) => value is String && _alphaNum.hasMatch(value), 'alphanumeric'); (value) => value is String && _alphaNum.hasMatch(value), 'alphanumeric');
@ -21,7 +24,8 @@ final Matcher isBool = predicate((value) => value is bool, 'a bool');
/// Asserts that a `String` complies to the RFC 5322 e-mail standard. /// Asserts that a `String` complies to the RFC 5322 e-mail standard.
final Matcher isEmail = predicate( final Matcher isEmail = predicate(
(value) => value is String && _email.hasMatch(value), 'a valid e-mail'); (value) => value is String && _email.hasMatch(value),
'a valid e-mail address');
/// Asserts that a value is an `int`. /// Asserts that a value is an `int`.
final Matcher isInt = predicate((value) => value is int, 'an integer'); final Matcher isInt = predicate((value) => value is int, 'an integer');
@ -30,12 +34,28 @@ final Matcher isInt = predicate((value) => value is int, 'an integer');
final Matcher isNum = predicate((value) => value is num, 'a number'); final Matcher isNum = predicate((value) => value is num, 'a number');
/// Asserts that a value is a `String`. /// Asserts that a value is a `String`.
final Matcher isString = predicate((value) => value is String, 'a String'); final Matcher isString = predicate((value) => value is String, 'a string');
/// Asserts that a value is a non-empty `String`. /// Asserts that a value is a non-empty `String`.
final Matcher isNonEmptyString = predicate( final Matcher isNonEmptyString = predicate(
(value) => value is String && value.trim().isNotEmpty, (value) => value is String && value.trim().isNotEmpty,
'a non-empty String'); 'a non-empty string');
/// Asserts that a value, presumably from a checkbox, is positive.
final Matcher isChecked =
isIn(const ['yes', 'checked', 'on', '1', 1, 1.0, true, 'true']);
/// Ensures that a string is an ISO-8601 date string.
final Matcher isIso8601DateString = predicate(
(x) {
try {
return x is String && DateTime.parse(x) != null;
} catch (_) {
return false;
}
},
'a valid ISO-8601 date string.',
);
/// Asserts that a `String` is an `http://` or `https://` URL. /// Asserts that a `String` is an `http://` or `https://` URL.
/// ///
@ -60,3 +80,54 @@ Matcher minLength(int length) => predicate(
Matcher maxLength(int length) => predicate( Matcher maxLength(int length) => predicate(
(value) => value is String && value.length >= length, (value) => value is String && value.length >= length,
'a string no longer than $length character(s) long'); 'a string no longer than $length character(s) long');
/// Asserts that for a key `x`, the context contains an identical item `x_confirmed`.
ContextAwareMatcher isConfirmed = predicateWithContext(
(item, key, context, matchState) {
return equals(item).matches(context['${key}_confirmed'], matchState);
},
'is confirmed',
);
/// Asserts that for a key `x`, the value of `x` is **not equal to** the value for [key].
ContextAwareMatcher differentFrom(String key) {
return predicateWithContext(
(item, key, context, matchState) {
return !equals(item).matches(context[key], matchState);
},
'is different from the value of "$key"',
);
}
/// Asserts that for a key `x`, the value of `x` is **equal to** the value for [key].
ContextAwareMatcher sameAs(String key) {
return predicateWithContext(
(item, key, context, matchState) {
return equals(item).matches(context[key], matchState);
},
'is equal to the value of "$key"',
);
}
/// Assert that a key `x` is present, if *all* of the given [keys] are as well.
ContextValidator requiredIf(Iterable<String> keys) =>
_require((ctx) => keys.every(ctx.containsKey));
/// Assert that a key `x` is present, if *any* of the given [keys] are as well.
ContextValidator requiredAny(Iterable<String> keys) =>
_require((ctx) => keys.any(ctx.containsKey));
/// Assert that a key `x` is present, if *at least one* of the given [keys] is not.
ContextValidator requiredWithout(Iterable<String> keys) =>
_require((ctx) => !keys.every(ctx.containsKey));
/// Assert that a key `x` is present, if *none* of the given [keys] are.
ContextValidator requiredWithoutAll(Iterable<String> keys) =>
_require((ctx) => !keys.any(ctx.containsKey));
ContextValidator _require(bool Function(Map) f) {
return new ContextValidator(
(key, context) => f(context) && context.containsKey(key),
(desc, key, _) => new StringDescription('Missing required field "$key".'),
);
}

View file

@ -1,5 +1,7 @@
import 'package:angel_http_exception/angel_http_exception.dart'; import 'package:angel_http_exception/angel_http_exception.dart';
import 'package:matcher/matcher.dart'; import 'package:matcher/matcher.dart';
import 'context_aware.dart';
import 'context_validator.dart';
final RegExp _asterisk = new RegExp(r'\*$'); final RegExp _asterisk = new RegExp(r'\*$');
final RegExp _forbidden = new RegExp(r'!$'); final RegExp _forbidden = new RegExp(r'!$');
@ -113,6 +115,9 @@ class Validator extends Matcher {
_importSchema(schema); _importSchema(schema);
} }
static bool _hasContextValidators(Iterable it) =>
it.any((x) => x is ContextValidator);
/// Validates, and filters input data. /// Validates, and filters input data.
ValidationResult check(Map inputData) { ValidationResult check(Map inputData) {
List<String> errors = []; List<String> errors = [];
@ -136,6 +141,7 @@ class Validator extends Matcher {
} }
for (String field in requiredFields) { for (String field in requiredFields) {
if (!_hasContextValidators(rules[field] ?? [])) {
if (!input.containsKey(field)) { if (!input.containsKey(field)) {
if (!customErrorMessages.containsKey(field)) if (!customErrorMessages.containsKey(field))
errors.add("'$field' is required."); errors.add("'$field' is required.");
@ -143,6 +149,9 @@ class Validator extends Matcher {
errors.add(customError(field, 'none')); errors.add(customError(field, 'none'));
} }
} }
}
// Run context validators.
for (var key in input.keys) { for (var key in input.keys) {
if (key is String && rules.containsKey(key)) { if (key is String && rules.containsKey(key)) {
@ -150,6 +159,19 @@ class Validator extends Matcher {
var value = input[key]; var value = input[key];
var description = new StringDescription("'$key': expected "); var description = new StringDescription("'$key': expected ");
for (var matcher in rules[key]) {
if (matcher is ContextValidator) {
if (!matcher.validate(key, input)) {
errors.add(matcher
.errorMessage(description, key, input)
.toString()
.trim());
valid = false;
}
}
}
if (valid) {
for (Matcher matcher in rules[key]) { for (Matcher matcher in rules[key]) {
try { try {
if (matcher is Validator) { if (matcher is Validator) {
@ -161,7 +183,15 @@ class Validator extends Matcher {
break; break;
} }
} else { } else {
if (!matcher.matches(value, {})) { bool result;
if (matcher is ContextAwareMatcher) {
result = matcher.matchesWithContext(value, key, input, {});
} else {
result = matcher.matches(value, {});
}
if (!result) {
if (!customErrorMessages.containsKey(key)) if (!customErrorMessages.containsKey(key))
errors.add(matcher.describe(description).toString().trim()); errors.add(matcher.describe(description).toString().trim());
valid = false; valid = false;
@ -174,6 +204,7 @@ class Validator extends Matcher {
break; break;
} }
} }
}
if (valid) { if (valid) {
data[key] = value; data[key] = value;
@ -187,7 +218,7 @@ class Validator extends Matcher {
return new ValidationResult().._errors.addAll(errors); return new ValidationResult().._errors.addAll(errors);
} }
return new ValidationResult().._data = data; return new ValidationResult().._data.addAll(data);
} }
/// Validates, and filters input data after running [autoParse]. /// Validates, and filters input data after running [autoParse].
@ -320,16 +351,23 @@ class Validator extends Matcher {
/// The result of attempting to validate input data. /// The result of attempting to validate input data.
class ValidationResult { class ValidationResult {
Map<String, dynamic> _data; final Map<String, dynamic> _data = {};
final List<String> _errors = []; final List<String> _errors = [];
/// The successfully validated data, filtered from the original input. /// The successfully validated data, filtered from the original input.
Map<String, dynamic> get data => _data; Map<String, dynamic> get data => new Map<String, dynamic>.unmodifiable(_data);
/// A list of errors that resulted in the given data being marked invalid. /// A list of errors that resulted in the given data being marked invalid.
/// ///
/// This is empty if validation was successful. /// This is empty if validation was successful.
List<String> get errors => new List<String>.unmodifiable(_errors); List<String> get errors => new List<String>.unmodifiable(_errors);
ValidationResult withData(Map<String, dynamic> data) => new ValidationResult()
.._data.addAll(data)
.._errors.addAll(_errors);
ValidationResult withErrors(Iterable<String> errors) =>
new ValidationResult().._data.addAll(_data).._errors.addAll(errors);
} }
/// Occurs when user-provided data is invalid. /// Occurs when user-provided data is invalid.

View file

@ -1,6 +1,6 @@
name: angel_validate name: angel_validate
description: Cross-platform validation library based on `matcher`. description: Cross-platform validation library based on `matcher`.
version: 1.0.4 version: 1.0.5-beta
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/validate homepage: https://github.com/angel-dart/validate
environment: environment:

0
test/async_test.dart Normal file
View file

View file

@ -31,6 +31,7 @@ main() {
expect(() { expect(() {
todoSchema todoSchema
.enforce({'id': 'fool', 'text': 'Hello, world!', 'completed': 4}); .enforce({'id': 'fool', 'text': 'Hello, world!', 'completed': 4});
// ignore: deprecated_member_use
}, throwsA(new isInstanceOf<ValidationException>())); }, throwsA(new isInstanceOf<ValidationException>()));
}); });

View file