From 2b3fb1c3e37c98249efa4d20c6ce174ccc6cb178 Mon Sep 17 00:00:00 2001 From: Jakub Tarkowski <45311300+jtarkowski27@users.noreply.github.com> Date: Tue, 26 Sep 2023 12:48:00 +0200 Subject: [PATCH] Make "SafeCqrs" the default CQRS client (#148) * Add cqrs_wrapper package * Add cqrs dependency * Add CqrsWrapper class * Add CqrsError classes * Add equatable dependency * Update readme * Add CqrsResult classes * Implement CqrsResult sealed classes * Add isInvalid check to CqrsResult * Differentiate command and query results * Implement noThrowGet and noThrowRun * Make cqrs results more generic * Rewrite Cqrs errors * Rewrite CqrsResult * Simplify Cqrs errors to just enums * Add missing error handlers to CqrsWrapper * Add isFailure getter to CqrsCommandResult * Implement missing cases for error handling in CqrsWrapper * Add initial library docs * Move CqrsWrapper to cqrs library * Add initial docs for CqrsWrapper and make logger optional * Add docs for CqrsWrapper.noThrowGet * Add headers argument to noThrowGet and noThrowRun * Add docs for CqrsWrapper * Add initial docs for CqrsResult * Make error argument in CqrsCommandResult.nonValidationError non nullable * Refactor CqrsResult * Refactor CqrsWrapper * Make CqrsWrapper implement Cqrs * Add docs for CqrsResult * Add CqrsCommandResultValidationErrorExtension * Add connectivity_plus to deps * Reimplement Cqrs.get * Specify error differentiation * Remove old files * Add cqrs exports * Add cqrs result classes * Reimplement Cqrs.run * Fix docs for CqrsError * Update docs for Cqrs.run * Add missing docs for Cqrs * Add middleware mechanism to Cqrs * Add default value of headers argument in Cqrs.run * Add tests for Cqrs.get * Remove connectivity_plus from deps * Update main docs * Organize file of CqrsError * Add default middleware method bodies * Add license docs to result classes * Organize imports * Add missing authentication and forbiddenAccess error cases in Cqrs.run * Make ValidationError extend Equatable * Add Cqrs.run tests * Add CQRS operation result * Reimplement Cqrs.perform * Add default implementation of CqrsMiddleware.handleOperationResult * Update Cqrs tests for methods get, run and perform * Revert entry doc change * Add extension with isInvalid getter for CqrsCommandResult specifically * Fix Cqrs logging strings * Add tests for Cqrs result classes * Revert entry do change v2 * Remove cqrs_exception_test.dart * Add CqrsCommandResult tests * Revert unwanted change in docs * Update Cqrs.addMiddleware docs * Add tests ensuring Cqrs middlewares handled porperly * Add tests for CqrsMiddleware * Fix tests for CqrsMiddleware.handleOperationResult * Implement Equatable correctly * Update CqrsCommandSuccess field tests * Remove CqrsException * Remove CqrsException from exports * Make error not generic in Cqrs result classes * Fix props override in CqrsCommandSuccess * Rename CqrsQueryResult to QResult * Rename CqrsCommandResult to CResult * Rename CqrsOperationResult to OResult * Update main docs * Add missing tests for CSuccess * Simplify result classes getters * Update result classes tests * Update CqrsError docs * Fix typo in docs * Exhaust case with Operation() * Exhaust all the rest _ResultType in case explicitly * Return early from _log * Rename CommandResult to CommandResponse * Reimplement cqrs result classes * Make base result classes private * Refactor result classes * Simplify CqrsMiddleware.handleResult * Remove CqrsError * Refactor Cqrs._log * Add ValidationError.props test * Update test for ValidationError.props * Fix docs for result classes * Revert making result classes a typedef of one base result class * Move ValidationError to separate file and make CommandResponse package private * Rename command_result.dart to command_response.dart * Rename OResult to OperationResult * Rename CResult to CommandResult * Rename QResult to QueryResult * Fix tests of CommandResponse * Add null check on logger in Cqrs._log method * Split CqrsError into Query Command and Operation Error * Make CqrsMethod a sealed class * Fix docs typos from code review Co-authored-by: Marcin Wojnarowski * Update CHANGELOG.md * Remove blank space in CHANGELOG.md * Update README.md * Update versions of dependencies in CHANGELOG.md Co-authored-by: Marcin Wojnarowski --------- Co-authored-by: Marcin Wojnarowski --- packages/cqrs/CHANGELOG.md | 12 + packages/cqrs/README.md | 12 +- packages/cqrs/lib/cqrs.dart | 22 +- ...mand_result.dart => command_response.dart} | 56 +- packages/cqrs/lib/src/cqrs.dart | 317 +++++++-- packages/cqrs/lib/src/cqrs_error.dart | 61 ++ packages/cqrs/lib/src/cqrs_exception.dart | 46 -- packages/cqrs/lib/src/cqrs_middleware.dart | 44 ++ packages/cqrs/lib/src/cqrs_result.dart | 140 ++++ packages/cqrs/lib/src/transport_types.dart | 2 +- packages/cqrs/lib/src/validation_error.dart | 35 + packages/cqrs/pubspec.yaml | 2 + ...t_test.dart => command_response_test.dart} | 39 +- packages/cqrs/test/cqrs_exception_test.dart | 37 - packages/cqrs/test/cqrs_middleware_test.dart | 35 + packages/cqrs/test/cqrs_result_test.dart | 86 +++ packages/cqrs/test/cqrs_test.dart | 654 ++++++++++++++++-- packages/cqrs/test/validation_error_test.dart | 6 +- 18 files changed, 1311 insertions(+), 295 deletions(-) rename packages/cqrs/lib/src/{command_result.dart => command_response.dart} (52%) create mode 100644 packages/cqrs/lib/src/cqrs_error.dart delete mode 100644 packages/cqrs/lib/src/cqrs_exception.dart create mode 100644 packages/cqrs/lib/src/cqrs_middleware.dart create mode 100644 packages/cqrs/lib/src/cqrs_result.dart create mode 100644 packages/cqrs/lib/src/validation_error.dart rename packages/cqrs/test/{command_result_test.dart => command_response_test.dart} (83%) delete mode 100644 packages/cqrs/test/cqrs_exception_test.dart create mode 100644 packages/cqrs/test/cqrs_middleware_test.dart create mode 100644 packages/cqrs/test/cqrs_result_test.dart diff --git a/packages/cqrs/CHANGELOG.md b/packages/cqrs/CHANGELOG.md index b54bfbaf..dc115d7d 100644 --- a/packages/cqrs/CHANGELOG.md +++ b/packages/cqrs/CHANGELOG.md @@ -1,3 +1,15 @@ +# 10.0.0 + +- **Breaking:** Fundamentaly change overall `Cqrs` API making it no-throw guarantee. +- **Breaking:** Make `Cqrs.get`, `Cqrs.run` and `Cqrs.perform` return result data in form of `QueryResult`, `CommandResult` and `OperationResult` respectively. +- Add `logger` parameter (of type `Logger` from `logging` package) to `Cqrs` default constructor. If provided, the `logger` will be used as a debug logging interface in execution of CQRS methods. +- Add middleware mechanism in form of `CqrsMiddleware` intended to use in processing result from queries, commands and operations. +- **Breaking:** Remove `CqrsException`. +- **Breaking:** Rename previous `CommandResult` to `CommandResponse` and make it package private. +- Mark the `CqrsMethod` as `sealed`. +- **Breaking:** Make `ValidationError` extend `Equatable` from `equatable` package. +- **Breaking:** Add `equatable` (`^2.0.5`) and `logging` (`^1.2.0`) dependencies. + # 9.0.0 - Bumped `http` dependency to `1.0.0`. (#105) diff --git a/packages/cqrs/README.md b/packages/cqrs/README.md index 2df45cd5..192333ec 100644 --- a/packages/cqrs/README.md +++ b/packages/cqrs/README.md @@ -46,10 +46,6 @@ class AddFlower implements Command { Map toJson() => {'Name': name, 'Pretty': pretty}; } -abstract class AddFlowerErrorCodes { - static const alreadyExists = 1; -} - // Firstly you need an Uri to which the requests will be sent. // Remember about the trailing slash as otherwise resolved paths // may be invalid. @@ -67,11 +63,11 @@ final flowers = await cqrs.get(AllFlowers(page: 1)); final result = await cqrs.run(AddFlower(name: 'Daisy', pretty: true)); // You can check the command result for its status, whether it successfully ran. -if (result.success) { +if (result case CommandSuccess()) { print('Added a daisy successfully!'); -} else if (result.hasError(AddFlowerErrorCodes.alreadyExists)) { - // Or check for errors in `result.errors`. You can use a `hasError` helper. - print('Daisy already exists!'); +} else if (result case CommandFailure(isInvalid: true, :final validationErrors)) { + print('Validation errors occured!'); + handleValidationErrors(validationErrors); } else { print('Error occured'); } diff --git a/packages/cqrs/lib/cqrs.dart b/packages/cqrs/lib/cqrs.dart index 5bce8095..fca576c3 100644 --- a/packages/cqrs/lib/cqrs.dart +++ b/packages/cqrs/lib/cqrs.dart @@ -28,6 +28,13 @@ /// // Fetching first page of flowers /// final flowers = await cqrs.get(AllFlowers(page: 1)); /// +/// // Handling query result +/// if (flowers case QuerySuccess(:final data)) { +/// print(data); +/// } else if (flowers case QueryFailure(:final error)) { +/// print('Something failed with error $error'); +/// } +/// /// // Adding a new flower /// final result = await cqrs.run( /// AddFlower( @@ -36,7 +43,15 @@ /// ), /// ); /// -/// print(result.success); // true +/// // Handling command result +/// if (result case CommandSuccess()) { +/// print('Flower added succefully'); +/// } else if (result case CommandFailure(isInvalid: true, :final validationErrors)) { +/// print('Validation errors occured'); +/// handleValidationErrors(validationErrors); +/// } else if (result case CommandFailure(:final error)) { +/// print('Something failed with error ${error}'); +/// } /// ``` /// /// See also: @@ -49,7 +64,8 @@ /// code contract generator. library; -export 'src/command_result.dart'; export 'src/cqrs.dart'; -export 'src/cqrs_exception.dart'; +export 'src/cqrs_error.dart'; +export 'src/cqrs_result.dart'; export 'src/transport_types.dart'; +export 'src/validation_error.dart'; diff --git a/packages/cqrs/lib/src/command_result.dart b/packages/cqrs/lib/src/command_response.dart similarity index 52% rename from packages/cqrs/lib/src/command_result.dart rename to packages/cqrs/lib/src/command_response.dart index 4fe165da..9307dc0f 100644 --- a/packages/cqrs/lib/src/command_result.dart +++ b/packages/cqrs/lib/src/command_response.dart @@ -13,20 +13,21 @@ // limitations under the License. import 'transport_types.dart'; +import 'validation_error.dart'; /// The result of running a [Command]. -class CommandResult { - /// Creates a [CommandResult] with [errors]; - const CommandResult(this.errors); +class CommandResponse { + /// Creates a [CommandResponse] with [errors]; + const CommandResponse(this.errors); - /// Creates a success [CommandResult] without any errors. - const CommandResult.success() : errors = const []; + /// Creates a success [CommandResponse] without any errors. + const CommandResponse.success() : errors = const []; - /// Creates a failed [CommandResult] and ensures it has errors. - CommandResult.failed(this.errors) : assert(errors.isNotEmpty); + /// Creates a failed [CommandResponse] and ensures it has errors. + CommandResponse.failed(this.errors) : assert(errors.isNotEmpty); - /// Creates a [CommandResult] from JSON. - CommandResult.fromJson(Map json) + /// Creates a [CommandResponse] from JSON. + CommandResponse.fromJson(Map json) : errors = (json['ValidationErrors'] as List) .map( (dynamic error) => @@ -43,16 +44,16 @@ class CommandResult { /// Validation errors related to the data carried by the [Command]. final List errors; - /// Checks whether this [CommandResult] contains a provided error `code` in + /// Checks whether this [CommandResponse] contains a provided error `code` in /// its validation errors. bool hasError(int code) => errors.any((error) => error.code == code); - /// Checks whether this [CommandResult] contains a provided error `code` in + /// Checks whether this [CommandResponse] contains a provided error `code` in /// its validation errors related to the `propertyName`. bool hasErrorForProperty(int code, String propertyName) => errors .any((error) => error.code == code && error.propertyName == propertyName); - /// Serializes this [CommandResult] to JSON. + /// Serializes this [CommandResponse] to JSON. Map toJson() => { 'WasSuccessful': success, 'ValidationErrors': errors.map((error) => error.toJson()).toList(), @@ -61,34 +62,3 @@ class CommandResult { @override String toString() => 'CommandResult($errors)'; } - -/// A validation error. -class ValidationError { - /// Creates a [ValidationError] from [code], [message], and [propertyName]. - const ValidationError(this.code, this.message, this.propertyName); - - /// Creates a [ValidationError] from JSON. - ValidationError.fromJson(Map json) - : code = json['ErrorCode'] as int, - message = json['ErrorMessage'] as String, - propertyName = json['PropertyName'] as String; - - /// Code of the validation error. - final int code; - - /// Message describing the validation error. - final String message; - - /// Path to the property which caused the error. - final String propertyName; - - /// Serializes this [ValidationError] to JSON. - Map toJson() => { - 'ErrorCode': code, - 'ErrorMessage': message, - 'PropertyName': propertyName - }; - - @override - String toString() => '[$propertyName] $code: $message'; -} diff --git a/packages/cqrs/lib/src/cqrs.dart b/packages/cqrs/lib/src/cqrs.dart index d31922e4..9807ab2e 100644 --- a/packages/cqrs/lib/src/cqrs.dart +++ b/packages/cqrs/lib/src/cqrs.dart @@ -14,12 +14,37 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; -import 'command_result.dart'; -import 'cqrs_exception.dart'; +import 'command_response.dart'; +import 'cqrs_error.dart'; +import 'cqrs_middleware.dart'; +import 'cqrs_result.dart'; import 'transport_types.dart'; +import 'validation_error.dart'; + +enum _ResultType { + success, + jsonError, + networkError, + authenticationError, + forbiddenAccessError, + validationError, + unknownError; + + String get description => switch (this) { + success => 'executed successfully', + jsonError => 'failed while decoding response body JSON', + networkError => 'failed with network error', + authenticationError => 'failed with authentication error', + forbiddenAccessError => 'failed with forbidden access error', + validationError => 'failed with validation errors', + unknownError => 'failed unexpectedly', + }; +} /// Class used for communicating with the backend via queries and commands. class Cqrs { @@ -35,18 +60,43 @@ class Cqrs { /// The `timeout` defaults to 30 seconds. `headers` have lesser priority than /// those provided directly into [get] or [run] methods and will be overrided /// by those in case of some headers sharing the same key. + /// + /// Any result (be it success or failure) of CQRS method will be logged + /// given the `logger` is provided. + /// + /// In case when a global result handling is needed, one might provide + /// a `middlewares` list with a collection of [CqrsMiddleware] objects. Every + /// time a result is returned, [Cqrs.get] and [Cqrs.run] will execute for + /// each middleware on the list [CqrsMiddleware.handleQueryResult] + /// and [CqrsMiddleware.handleCommandResult] accordingly. Cqrs( this._client, this._apiUri, { Duration timeout = const Duration(seconds: 30), Map headers = const {}, + Logger? logger, + List middlewares = const [], }) : _timeout = timeout, - _headers = headers; + _headers = headers, + _logger = logger, + _middlewares = [...middlewares]; final http.Client _client; final Uri _apiUri; final Duration _timeout; final Map _headers; + final Logger? _logger; + final List _middlewares; + + /// Add given middleware at the end of a list. + void addMiddleware(CqrsMiddleware middleware) { + _middlewares.add(middleware); + } + + /// Remove given middleware from the list. + void removeMiddleware(CqrsMiddleware middleware) { + _middlewares.remove(middleware); + } /// Send a query to the backend and expect a result of the type `T`. /// @@ -54,29 +104,18 @@ class Cqrs { /// constructor, meaning `headers` override `_headers`. `Content-Type` header /// will be ignored. /// - /// A [CqrsException] will be thrown in case of an error. - Future get( + /// After successful completion returns [QuerySuccess] with received data + /// of type `T`. A [QueryFailure] will be returned with an according + /// [QueryError] in case of an error. + Future> get( Query query, { Map headers = const {}, }) async { - final response = await _send(query, pathPrefix: 'query', headers: headers); - - if (response.statusCode == 200) { - try { - final dynamic json = jsonDecode(response.body); - - return query.resultFactory(json); - } catch (e) { - throw CqrsException( - response, - 'An error occured while decoding response body JSON:\n$e', - ); - } - } + final result = await _get(query, headers: headers); - throw CqrsException( - response, - 'Invalid, non 200 status code returned by ${query.getFullName()} query.', + return _middlewares.fold( + result, + (result, middleware) async => middleware.handleQueryResult(await result), ); } @@ -87,34 +126,20 @@ class Cqrs { /// constructor, meaning `headers` override `_headers`. `Content-Type` header /// will be ignored. /// - /// A [CqrsException] will be thrown in case of an error. + /// After successful completion returns [CommandSuccess]. + /// A [CommandFailure] will be returned with an according [CommandError] + /// in case of an error and with list of [ValidationError] errors (in case of + /// validation error). Future run( Command command, { Map headers = const {}, }) async { - final response = await _send( - command, - pathPrefix: 'command', - headers: headers, - ); - - if ([200, 422].contains(response.statusCode)) { - try { - final json = jsonDecode(response.body) as Map; - - return CommandResult.fromJson(json); - } catch (e) { - throw CqrsException( - response, - 'An error occured while decoding response body JSON:\n$e', - ); - } - } + final result = await _run(command, headers: headers); - throw CqrsException( - response, - 'Invalid, non 200 or 422 status code returned ' - 'by ${command.getFullName()} command.', + return _middlewares.fold( + result, + (result, middleware) async => + middleware.handleCommandResult(await result), ); } @@ -124,31 +149,151 @@ class Cqrs { /// constructor, meaning `headers` override `_headers`. `Content-Type` header /// will be ignored. /// - /// A [CqrsException] will be thrown in case of an error. - Future perform( + /// After successful completion returns [OperationSuccess] with received + /// data of type `T`. A [OperationFailure] will be returned with + /// an according [OperationError] in case of an error. + Future> perform( Operation operation, { Map headers = const {}, }) async { - final response = - await _send(operation, pathPrefix: 'operation', headers: headers); - - if (response.statusCode == 200) { - try { - final dynamic json = jsonDecode(response.body); - - return operation.resultFactory(json); - } catch (e) { - throw CqrsException( - response, - 'An error occured while decoding response body JSON:\n$e', - ); + final result = await _perform(operation, headers: headers); + + return _middlewares.fold( + result, + (result, middleware) async => + middleware.handleOperationResult(await result), + ); + } + + Future> _get( + Query query, { + required Map headers, + }) async { + try { + final response = + await _send(query, pathPrefix: 'query', headers: headers); + + if (response.statusCode == 200) { + try { + final dynamic json = jsonDecode(response.body); + final result = query.resultFactory(json); + _log(query, _ResultType.success); + return QuerySuccess(result); + } catch (e, s) { + _log(query, _ResultType.jsonError, e, s); + return QueryFailure(QueryError.unknown); + } } + + if (response.statusCode == 401) { + _log(query, _ResultType.authenticationError); + return QueryFailure(QueryError.authentication); + } + if (response.statusCode == 403) { + _log(query, _ResultType.forbiddenAccessError); + return QueryFailure(QueryError.forbiddenAccess); + } + } on SocketException catch (e, s) { + _log(query, _ResultType.networkError, e, s); + return QueryFailure(QueryError.network); + } catch (e, s) { + _log(query, _ResultType.unknownError, e, s); + return QueryFailure(QueryError.unknown); } - throw CqrsException( - response, - 'Invalid, non 200 status code returned by ${operation.getFullName()} operation.', - ); + _log(query, _ResultType.unknownError); + return QueryFailure(QueryError.unknown); + } + + Future _run( + Command command, { + required Map headers, + }) async { + try { + final response = await _send( + command, + pathPrefix: 'command', + headers: headers, + ); + + if ([200, 422].contains(response.statusCode)) { + try { + final json = jsonDecode(response.body) as Map; + final result = CommandResponse.fromJson(json); + + if (response.statusCode == 200) { + _log(command, _ResultType.success); + return const CommandSuccess(); + } + + _log(command, _ResultType.validationError, null, null, result.errors); + return CommandFailure( + CommandError.validation, + validationErrors: result.errors, + ); + } catch (e, s) { + _log(command, _ResultType.jsonError, e, s); + return const CommandFailure(CommandError.unknown); + } + } + if (response.statusCode == 401) { + _log(command, _ResultType.authenticationError); + return const CommandFailure(CommandError.authentication); + } + if (response.statusCode == 403) { + _log(command, _ResultType.forbiddenAccessError); + return const CommandFailure(CommandError.forbiddenAccess); + } + } on SocketException catch (e, s) { + _log(command, _ResultType.networkError, e, s); + return const CommandFailure(CommandError.network); + } catch (e, s) { + _log(command, _ResultType.unknownError, e, s); + return const CommandFailure(CommandError.unknown); + } + + _log(command, _ResultType.unknownError); + return const CommandFailure(CommandError.unknown); + } + + Future> _perform( + Operation operation, { + Map headers = const {}, + }) async { + try { + final response = + await _send(operation, pathPrefix: 'operation', headers: headers); + + if (response.statusCode == 200) { + try { + final dynamic json = jsonDecode(response.body); + final result = operation.resultFactory(json); + _log(operation, _ResultType.success); + return OperationSuccess(result); + } catch (e, s) { + _log(operation, _ResultType.jsonError, e, s); + return OperationFailure(OperationError.unknown); + } + } + + if (response.statusCode == 401) { + _log(operation, _ResultType.authenticationError); + return OperationFailure(OperationError.authentication); + } + if (response.statusCode == 403) { + _log(operation, _ResultType.forbiddenAccessError); + return OperationFailure(OperationError.forbiddenAccess); + } + } on SocketException catch (e, s) { + _log(operation, _ResultType.networkError, e, s); + return OperationFailure(OperationError.network); + } catch (e, s) { + _log(operation, _ResultType.unknownError, e, s); + return OperationFailure(OperationError.unknown); + } + + _log(operation, _ResultType.unknownError); + return OperationFailure(OperationError.unknown); } Future _send( @@ -166,4 +311,48 @@ class Cqrs { }, ).timeout(_timeout); } + + void _log( + CqrsMethod method, + _ResultType result, [ + Object? error, + StackTrace? stackTrace, + List validationErrors = const [], + ]) { + final logger = _logger; + if (logger == null) { + return; + } + + final log = switch (result) { + _ResultType.success => logger.info, + _ResultType.validationError => logger.warning, + _ResultType.jsonError || + _ResultType.networkError || + _ResultType.authenticationError || + _ResultType.forbiddenAccessError || + _ResultType.unknownError => + logger.severe, + }; + + final methodTypePrefix = switch (method) { + Query() => 'Query', + Command() => 'Command', + Operation() => 'Operation', + }; + + final validationErrorsBuffer = StringBuffer(); + for (final error in validationErrors) { + validationErrorsBuffer.write('${error.message} (${error.code}), '); + } + + final details = switch (result) { + _ResultType.validationError => + '$methodTypePrefix ${method.runtimeType} ${result.description}:\n' + '$validationErrorsBuffer', + _ => '$methodTypePrefix ${method.runtimeType} ${result.description}.', + }; + + log.call(details, error, stackTrace); + } } diff --git a/packages/cqrs/lib/src/cqrs_error.dart b/packages/cqrs/lib/src/cqrs_error.dart new file mode 100644 index 00000000..f01d3222 --- /dev/null +++ b/packages/cqrs/lib/src/cqrs_error.dart @@ -0,0 +1,61 @@ +// Copyright 2023 LeanCode Sp. z o.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Error types of query failure. +enum QueryError { + /// Represents a network/socket error. + network, + + /// Represents a HTTP 401 authentication error. + authentication, + + /// Represents a HTTP 403 forbidden access error. + forbiddenAccess, + + /// Represents a generic error which covers all remaining errors. + unknown, +} + +/// Error types of command failure. +enum CommandError { + /// Represents a network/socket error. + network, + + /// Represents a HTTP 401 authentication error. + authentication, + + /// Represents a HTTP 403 forbidden access error. + forbiddenAccess, + + /// Represents a HTTP 422 validation error. + validation, + + /// Represents a generic error which covers all remaining errors. + unknown, +} + +/// Error types of operation failure. +enum OperationError { + /// Represents a network/socket error. + network, + + /// Represents a HTTP 401 authentication error. + authentication, + + /// Represents a HTTP 403 forbidden access error. + forbiddenAccess, + + /// Represents a generic error which covers all remaining errors. + unknown, +} diff --git a/packages/cqrs/lib/src/cqrs_exception.dart b/packages/cqrs/lib/src/cqrs_exception.dart deleted file mode 100644 index 525a7477..00000000 --- a/packages/cqrs/lib/src/cqrs_exception.dart +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2023 LeanCode Sp. z o.o. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'package:cqrs/cqrs.dart'; -import 'package:http/http.dart'; - -/// Usually thrown by [Cqrs.get] or [Cqrs.run]. -class CqrsException implements Exception { - /// Creates a [CqrsException] with [response] and [message]. - const CqrsException(this.response, [this.message]); - - /// Server's response to the request that triggered this exception. - final Response response; - - /// Server's message. - final String? message; - - @override - String toString() { - final builder = StringBuffer(); - - if (message != null) { - builder.writeln(message); - } - - builder - ..writeln( - 'Server returned a ${response.statusCode} ${response.reasonPhrase} ' - 'status. Response body:', - ) - ..write(response.body); - - return builder.toString(); - } -} diff --git a/packages/cqrs/lib/src/cqrs_middleware.dart b/packages/cqrs/lib/src/cqrs_middleware.dart new file mode 100644 index 00000000..1c848153 --- /dev/null +++ b/packages/cqrs/lib/src/cqrs_middleware.dart @@ -0,0 +1,44 @@ +// Copyright 2023 LeanCode Sp. z o.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'cqrs_result.dart'; + +/// Abstract class for CQRS result handlers. Can be used to specify certain +/// behaviors that will be triggered for given errors i.e. showing snackbar +/// on network error or logging out on authetication error etc. +abstract class CqrsMiddleware { + /// Creates [CqrsMiddleware] class. + const CqrsMiddleware(); + + /// Handle and return query result. If no modification of given result + /// is needed then return the same `result` that was passed to the method. + Future> handleQueryResult( + QueryResult result, + ) => + Future.value(result); + + /// Handle and return command result. If no modification of given result + /// is needed then return the same `result` that was passed to the method. + Future handleCommandResult( + CommandResult result, + ) => + Future.value(result); + + /// Handle and return operation result. If no modification of given result + /// is needed then return the same `result` that was passed to the method. + Future> handleOperationResult( + OperationResult result, + ) => + Future.value(result); +} diff --git a/packages/cqrs/lib/src/cqrs_result.dart b/packages/cqrs/lib/src/cqrs_result.dart new file mode 100644 index 00000000..23d51789 --- /dev/null +++ b/packages/cqrs/lib/src/cqrs_result.dart @@ -0,0 +1,140 @@ +// Copyright 2023 LeanCode Sp. z o.o. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:equatable/equatable.dart'; + +import 'cqrs_error.dart'; +import 'validation_error.dart'; + +/// Generic result for CQRS query result. Can be either [QuerySuccess] +/// or [QueryFailure]. +sealed class QueryResult extends Equatable { + /// Creates a [QueryResult] class. + const QueryResult(); + + /// Whether this instance is of final type [QuerySuccess]. + bool get isSuccess => this is QuerySuccess; + + /// Whether this instance is of final type [QueryFailure]. + bool get isFailure => this is QueryFailure; +} + +/// Generic class which represents a result of succesful query execution. +final class QuerySuccess extends QueryResult { + /// Creates a [QuerySuccess] class. + const QuerySuccess(this.data); + + /// Data of type [T] returned from query execution. + final T data; + + @override + List get props => [data]; +} + +/// Generic class which represents a result of unsuccesful query execution. +final class QueryFailure extends QueryResult { + /// Creates a [QueryFailure] class. + const QueryFailure(this.error); + + /// Error which was the reason of query failure + final QueryError error; + + @override + List get props => [error]; +} + +/// Generic result for CQRS command result. Can be either [CommandSuccess] +/// or [CommandFailure]. +sealed class CommandResult extends Equatable { + /// Creates a [CommandResult] class. + const CommandResult(); + + /// Whether this instance is of final type [CommandSuccess]. + bool get isSuccess => this is CommandSuccess; + + /// Whether this instance is of final type [CommandFailure]. + bool get isFailure => this is CommandFailure; + + /// Whether this instance is of final type [CommandFailure] and comes + /// from validation error. + bool get isInvalid => switch (this) { + CommandFailure(error: CommandError.validation) => true, + _ => false, + }; +} + +/// Generic class which represents a result of succesful command execution. +final class CommandSuccess extends CommandResult { + /// Creates a [CommandSuccess] class. + const CommandSuccess(); + + @override + List get props => []; +} + +/// Generic class which represents a result of unsuccesful command execution. +final class CommandFailure extends CommandResult { + /// Creates a [CommandFailure] class. + const CommandFailure( + this.error, { + this.validationErrors = const [], + }); + + /// Error which was the reason of query failure + final CommandError error; + + /// A list of [ValidationError] errors returned from the backed after + /// command execution. + final List validationErrors; + + @override + List get props => [error, validationErrors]; +} + +/// Generic result for CQRS operation result. Can be either [OperationSuccess] +/// or [OperationFailure]. +sealed class OperationResult extends Equatable { + /// Creates a [OperationResult] class. + const OperationResult(); + + /// Whether this instance is of final type [OperationSuccess]. + bool get isSuccess => this is OperationSuccess; + + /// Whether this instance is of final type [OperationFailure]. + bool get isFailure => this is OperationFailure; +} + +/// Generic class which represents a result of succesful operation execution. +final class OperationSuccess extends OperationResult { + /// Creates a [OperationSuccess] class. + const OperationSuccess(this.data); + + /// Data of type [T] returned from operation execution. + final T data; + + @override + List get props => [data]; +} + +/// Generic class which represents a result of unsuccesful operation execution. +final class OperationFailure extends OperationResult { + /// Creates a [OperationFailure] class. + const OperationFailure(this.error); + + /// Error which was the reason of operation failure + final OperationError error; + + @override + List get props => [error]; +} diff --git a/packages/cqrs/lib/src/transport_types.dart b/packages/cqrs/lib/src/transport_types.dart index 29d9c02e..40866c23 100644 --- a/packages/cqrs/lib/src/transport_types.dart +++ b/packages/cqrs/lib/src/transport_types.dart @@ -13,7 +13,7 @@ // limitations under the License. /// An interface for contracts that can be serialized and sent to the backend. -abstract interface class CqrsMethod { +sealed class CqrsMethod { /// Returns a JSON-encoded representation of the data this class carries. Map toJson(); diff --git a/packages/cqrs/lib/src/validation_error.dart b/packages/cqrs/lib/src/validation_error.dart new file mode 100644 index 00000000..04d09be8 --- /dev/null +++ b/packages/cqrs/lib/src/validation_error.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +/// A validation error. +class ValidationError extends Equatable { + /// Creates a [ValidationError] from [code], [message], and [propertyName]. + const ValidationError(this.code, this.message, this.propertyName); + + /// Creates a [ValidationError] from JSON. + ValidationError.fromJson(Map json) + : code = json['ErrorCode'] as int, + message = json['ErrorMessage'] as String, + propertyName = json['PropertyName'] as String; + + /// Code of the validation error. + final int code; + + /// Message describing the validation error. + final String message; + + /// Path to the property which caused the error. + final String propertyName; + + /// Serializes this [ValidationError] to JSON. + Map toJson() => { + 'ErrorCode': code, + 'ErrorMessage': message, + 'PropertyName': propertyName, + }; + + @override + String toString() => '[$propertyName] $code: $message'; + + @override + List get props => [code, message, propertyName]; +} diff --git a/packages/cqrs/pubspec.yaml b/packages/cqrs/pubspec.yaml index 7736b3e5..68c7b236 100644 --- a/packages/cqrs/pubspec.yaml +++ b/packages/cqrs/pubspec.yaml @@ -10,7 +10,9 @@ environment: sdk: '>=3.0.1 <4.0.0' dependencies: + equatable: ^2.0.5 http: ^1.0.0 + logging: ^1.2.0 dev_dependencies: coverage: ^1.5.0 diff --git a/packages/cqrs/test/command_result_test.dart b/packages/cqrs/test/command_response_test.dart similarity index 83% rename from packages/cqrs/test/command_result_test.dart rename to packages/cqrs/test/command_response_test.dart index 031654d2..ad42755d 100644 --- a/packages/cqrs/test/command_result_test.dart +++ b/packages/cqrs/test/command_response_test.dart @@ -1,14 +1,15 @@ -import 'package:cqrs/src/command_result.dart'; +import 'package:cqrs/src/command_response.dart'; +import 'package:cqrs/src/validation_error.dart'; import 'package:test/test.dart'; void main() { - group('CommandResult', () { + group('CommandResponse', () { const error1 = ValidationError(1, 'First error', 'Property1'); const error2 = ValidationError(2, 'Second error', 'Property2'); group('fields values are correct', () { test('when constructed without errors', () { - const result = CommandResult([]); + const result = CommandResponse([]); expect(result.success, true); expect(result.failed, false); @@ -16,7 +17,7 @@ void main() { }); test('when constructed with errors', () { - const result = CommandResult([error1, error2]); + const result = CommandResponse([error1, error2]); expect(result.success, false); expect(result.failed, true); @@ -24,7 +25,7 @@ void main() { }); test('when constructed with success constructor', () { - const result = CommandResult.success(); + const result = CommandResponse.success(); expect(result.success, true); expect(result.failed, false); @@ -32,7 +33,7 @@ void main() { }); test('when constructed with success constructor', () { - final result = CommandResult.failed([error1]); + final result = CommandResponse.failed([error1]); expect(result.success, false); expect(result.failed, true); @@ -42,7 +43,7 @@ void main() { group('hasError returns correct values', () { test('when there are some errors present', () { - const result = CommandResult([error1, error2]); + const result = CommandResponse([error1, error2]); expect(result.hasError(1), true); expect(result.hasError(2), true); @@ -50,7 +51,7 @@ void main() { }); test('when there are no errors present', () { - const result = CommandResult([]); + const result = CommandResponse([]); expect(result.hasError(1), false); expect(result.hasError(2), false); @@ -60,21 +61,21 @@ void main() { group('hasErrorForProperty returns correct values', () { test('when there are some errors present', () { - const result = CommandResult([error1, error2]); + const result = CommandResponse([error1, error2]); expect(result.hasErrorForProperty(1, 'Property1'), true); expect(result.hasErrorForProperty(2, 'Property2'), true); }); test('when there are errors but for different properties', () { - const result = CommandResult([error1, error2]); + const result = CommandResponse([error1, error2]); expect(result.hasErrorForProperty(1, 'Property2'), false); expect(result.hasErrorForProperty(2, 'Property1'), false); }); test('when there are no errors present', () { - const result = CommandResult([]); + const result = CommandResponse([]); expect(result.hasErrorForProperty(1, 'Property1'), false); expect(result.hasErrorForProperty(2, 'Property1'), false); @@ -85,7 +86,7 @@ void main() { group('is correctly deserialized from JSON', () { test('with some validation errors', () { - final result = CommandResult.fromJson({ + final result = CommandResponse.fromJson({ 'WasSuccessful': false, 'ValidationErrors': [ { @@ -98,7 +99,7 @@ void main() { expect( result, - isA() + isA() .having((r) => r.success, 'success', false) .having( (r) => r.errors, @@ -118,14 +119,14 @@ void main() { }); test('without validation errors, with success', () { - final result = CommandResult.fromJson({ + final result = CommandResponse.fromJson({ 'WasSuccessful': true, 'ValidationErrors': >[], }); expect( result, - isA() + isA() .having((r) => r.success, 'success', true) .having((r) => r.errors, 'errors', isEmpty), ); @@ -134,7 +135,7 @@ void main() { group('is correctly serialized to JSON', () { test('with some validation errors', () { - final json = CommandResult.fromJson({ + final json = CommandResponse.fromJson({ 'WasSuccessful': false, 'ValidationErrors': [ { @@ -162,7 +163,7 @@ void main() { }); test('without validation errors, with success', () { - final json = CommandResult.fromJson({ + final json = CommandResponse.fromJson({ 'WasSuccessful': true, 'ValidationErrors': >[], }).toJson(); @@ -174,13 +175,13 @@ void main() { group('returns correct string value', () { test('for success', () { - const error = CommandResult.success(); + const error = CommandResponse.success(); expect(error.toString(), 'CommandResult([])'); }); test('for errors', () { - final error = CommandResult.failed(const [ + final error = CommandResponse.failed(const [ ValidationError(23, 'message', 'propertyName'), ValidationError(29, 'message2', 'propertyName2'), ]); diff --git a/packages/cqrs/test/cqrs_exception_test.dart b/packages/cqrs/test/cqrs_exception_test.dart deleted file mode 100644 index 1c302832..00000000 --- a/packages/cqrs/test/cqrs_exception_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:cqrs/src/cqrs_exception.dart'; -import 'package:http/http.dart'; -import 'package:test/test.dart'; - -void main() { - group('CQRSException', () { - final response = Response('', 401, reasonPhrase: 'Unauthorized'); - - group('has correct fields values', () { - test('with message present', () { - final exception1 = CqrsException(response, 'This is a message.'); - - expect(exception1.response, response); - expect(exception1.message, 'This is a message.'); - }); - - test('with message absent', () { - final exception2 = CqrsException(response); - - expect(exception2.response, response); - expect(exception2.message, isNull); - }); - }); - - test('correctly converts to String', () { - final exception1 = CqrsException(response, 'This is a message.'); - - expect( - exception1.toString(), - ''' -This is a message. -Server returned a 401 Unauthorized status. Response body: -''', - ); - }); - }); -} diff --git a/packages/cqrs/test/cqrs_middleware_test.dart b/packages/cqrs/test/cqrs_middleware_test.dart new file mode 100644 index 00000000..c5573484 --- /dev/null +++ b/packages/cqrs/test/cqrs_middleware_test.dart @@ -0,0 +1,35 @@ +import 'package:cqrs/cqrs.dart'; +import 'package:cqrs/src/cqrs_middleware.dart'; +import 'package:test/test.dart'; + +void main() { + final middleware = ExampleCqrsMiddleware(); + + group('CqrsMiddleware', () { + group('handleQueryResult', () { + test('returns the same result by default', () async { + const resultIn = QuerySuccess(true); + final resultOut = await middleware.handleQueryResult(resultIn); + expect(resultIn, resultOut); + }); + }); + + group('handleCommandResult', () { + test('returns the same result by default', () async { + const resultIn = CommandSuccess(); + final resultOut = await middleware.handleCommandResult(resultIn); + expect(resultIn, resultOut); + }); + }); + + group('handleOperationResult', () { + test('returns the same result by default', () async { + const resultIn = OperationSuccess(true); + final resultOut = await middleware.handleOperationResult(resultIn); + expect(resultIn, resultOut); + }); + }); + }); +} + +class ExampleCqrsMiddleware extends CqrsMiddleware {} diff --git a/packages/cqrs/test/cqrs_result_test.dart b/packages/cqrs/test/cqrs_result_test.dart new file mode 100644 index 00000000..90256781 --- /dev/null +++ b/packages/cqrs/test/cqrs_result_test.dart @@ -0,0 +1,86 @@ +import 'package:cqrs/cqrs.dart'; +import 'package:test/test.dart'; + +void main() { + group('QueryResult', () { + group('fields values are correct', () { + test('when constructed as success', () { + const result = QuerySuccess(true); + + expect(result.isSuccess, true); + expect(result.isFailure, false); + expect(result.data, true); + }); + + test('when constructed as failure', () { + const result = QueryFailure(QueryError.unknown); + + expect(result.isSuccess, false); + expect(result.isFailure, true); + expect(result.error, QueryError.unknown); + }); + }); + }); + + group('CommandResult', () { + group('fields values are correct', () { + test('when constructed as success', () { + const result = CommandSuccess(); + + expect(result.isSuccess, true); + expect(result.isFailure, false); + expect(result.isInvalid, false); + expect(result.props.isEmpty, true); + }); + + test('when constructed as failure without validation errors', () { + const result = CommandFailure(CommandError.unknown); + + expect(result.isSuccess, false); + expect(result.isFailure, true); + expect(result.isInvalid, false); + expect(result.validationErrors.isEmpty, true); + expect(result.error, CommandError.unknown); + }); + + test('when constructed as failure with validation errors', () { + const result = CommandFailure( + CommandError.validation, + validationErrors: [ + ValidationError(123, 'Test message', 'SomeProperty'), + ValidationError(456, 'Another message', 'OtherProperty'), + ], + ); + + expect(result.isSuccess, false); + expect(result.isFailure, true); + expect(result.isInvalid, true); + expect(result.validationErrors, const [ + ValidationError(123, 'Test message', 'SomeProperty'), + ValidationError(456, 'Another message', 'OtherProperty'), + ]); + expect(result.error, CommandError.validation); + }); + }); + }); + + group('OperationResult', () { + group('fields values are correct', () { + test('when constructed as success', () { + const result = OperationSuccess(true); + + expect(result.isSuccess, true); + expect(result.isFailure, false); + expect(result.data, true); + }); + + test('when constructed as failure', () { + const result = OperationFailure(OperationError.unknown); + + expect(result.isSuccess, false); + expect(result.isFailure, true); + expect(result.error, OperationError.unknown); + }); + }); + }); +} diff --git a/packages/cqrs/test/cqrs_test.dart b/packages/cqrs/test/cqrs_test.dart index e0c0ce16..5b68c498 100644 --- a/packages/cqrs/test/cqrs_test.dart +++ b/packages/cqrs/test/cqrs_test.dart @@ -1,31 +1,100 @@ -import 'package:cqrs/src/command_result.dart'; -import 'package:cqrs/src/cqrs.dart'; -import 'package:cqrs/src/cqrs_exception.dart'; -import 'package:cqrs/src/transport_types.dart'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:cqrs/cqrs.dart'; +import 'package:cqrs/src/cqrs_middleware.dart'; + import 'package:http/http.dart'; +import 'package:logging/logging.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; class MockClient extends Mock implements Client {} +class MockLogger extends Mock implements Logger {} + +class MockCqrsMiddleware extends Mock implements CqrsMiddleware {} + void main() { group('CQRS', () { late MockClient client; + late MockLogger logger; + late MockCqrsMiddleware middleware; late Cqrs cqrs; setUpAll(() { registerFallbackValue(Uri()); + registerFallbackValue(const QuerySuccess(true)); + registerFallbackValue( + Future.value(const QuerySuccess(true)), + ); + registerFallbackValue(const CommandSuccess()); + registerFallbackValue( + Future.value(const CommandSuccess()), + ); + registerFallbackValue(const OperationSuccess(true)); + registerFallbackValue( + Future.value(const OperationSuccess(true)), + ); }); setUp(() { client = MockClient(); - cqrs = Cqrs(client, Uri.parse('https://example.org/api/')); + logger = MockLogger(); + middleware = MockCqrsMiddleware(); + cqrs = Cqrs( + client, + Uri.parse('https://example.org/api/'), + logger: logger, + ); + }); + + group('addMiddleware', () { + test('adds new middleware to the list', () async { + mockClientPost(client, Response('true', 200)); + + var result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + verifyNever(() => middleware.handleQueryResult(any())); + + cqrs.addMiddleware(middleware); + result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + verify(() => middleware.handleQueryResult(result)).called(1); + + cqrs.removeMiddleware(middleware); + }); + }); + + group('removeMiddleware', () { + test('removes given middleware from the list', () async { + mockClientPost(client, Response('true', 200)); + + var result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + + verifyNever(() => middleware.handleQueryResult(result)); + + cqrs.addMiddleware(middleware); + result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + + verify(() => middleware.handleQueryResult(result)).called(1); + + cqrs.removeMiddleware(middleware); + result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + + verifyNever( + () => middleware.handleQueryResult(any()), + ); + }); }); group('get', () { test( - "correctly serializes query, calls client's send and" - ' deserializes result', () async { + "correctly serializes query, calls client's send," + ' deserializes result and logs result', () async { mockClientPost(client, Response('true', 200)); final result = await cqrs.get( @@ -33,7 +102,7 @@ void main() { headers: {'X-Test': 'foobar'}, ); - expect(result, true); + expect(result, const QuerySuccess(true)); verify( () => client.post( @@ -51,6 +120,10 @@ void main() { ), ), ).called(1); + + verify( + () => logger.info('Query ExampleQuery executed successfully.'), + ).called(1); }); test('correctly deserializes null query result', () async { @@ -58,49 +131,160 @@ void main() { final result = await cqrs.get(ExampleQuery()); - expect(result, null); + expect(result, const QuerySuccess(null)); }); - test('throws CQRSException on json decoding failure', () async { + test( + 'returns QueryFailure(QueryError.unknown) on json decoding' + ' failure and logs result', () async { mockClientPost(client, Response('true', 200)); - final result = cqrs.get(ExampleQueryFailingResultFactory()); + final result = await cqrs.get(ExampleQueryFailingResultFactory()); expect( result, - throwsA( - isA().having( - (e) => e.message, - 'message', - 'An error occured while decoding response body JSON:\n' - 'Exception: This is error.', - ), + const QueryFailure(QueryError.unknown), + ); + + verify( + () => logger.severe( + 'Query ExampleQueryFailingResultFactory failed while decoding' + ' response body JSON.', + any(), + any(), ), + ).called(1); + }); + + test( + 'returns QueryFailure(QueryError.network) on socket exception' + ' and logs result', () async { + mockClientException( + client, + const SocketException('This might be socket exception'), ); + + final result = await cqrs.get(ExampleQuery()); + + expect( + result, + const QueryFailure(QueryError.network), + ); + + verify( + () => logger.severe( + 'Query ExampleQuery failed with network error.', + any(), + any(), + ), + ).called(1); }); - test('throws CQRSException when response code is other than 200', () { - mockClientPost(client, Response('', 404)); + test( + 'returns QueryFailure(QueryError.unknown) on client exception' + ' and logs result', () async { + final exception = Exception('This is not a socket exception'); + mockClientException(client, exception); - final result = cqrs.get(ExampleQuery()); + final result = await cqrs.get(ExampleQuery()); expect( result, - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Invalid, non 200 status code returned by ExampleQuery query.', - ), + const QueryFailure(QueryError.unknown), + ); + + verify( + () => logger.severe( + 'Query ExampleQuery failed unexpectedly.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns QueryFailure(QueryError.authentication) when response' + ' code is 401 and logs result', () async { + mockClientPost(client, Response('', 401)); + + final result = await cqrs.get(ExampleQuery()); + + expect( + result, + const QueryFailure(QueryError.authentication), + ); + + verify( + () => logger.severe( + 'Query ExampleQuery failed with authentication error.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns QueryFailure(QueryError.forbiddenAccess) when response' + ' code is 403 and logs result', () async { + mockClientPost(client, Response('', 403)); + + final result = await cqrs.get(ExampleQuery()); + + expect( + result, + const QueryFailure(QueryError.forbiddenAccess), + ); + + verify( + () => logger.severe( + 'Query ExampleQuery failed with forbidden access error.', + any(), + any(), ), + ).called(1); + }); + + test( + 'returns QueryFailure(QueryError.unknown) for other response' + ' codes and logs result', () async { + mockClientPost(client, Response('', 404)); + + final result = await cqrs.get(ExampleQuery()); + + expect( + result, + const QueryFailure(QueryError.unknown), ); + + verify( + () => logger.severe( + 'Query ExampleQuery failed unexpectedly.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'calls CqrsMiddleware.handleQueryResult for each' + ' middleware present', () async { + mockClientPost(client, Response('true', 200)); + + var result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + verifyNever(() => middleware.handleQueryResult(any())); + + cqrs.addMiddleware(middleware); + result = await cqrs.get(ExampleQuery()); + mockCqrsMiddlewareQueryResult(middleware, result); + verify(() => middleware.handleQueryResult(result)).called(1); }); }); group('run', () { test( - "correctly serializes command, calls client's send and deserializes " - 'command results', () async { + "correctly serializes command, calls client's send, deserializes " + 'command results and logs result', () async { mockClientPost( client, Response('{"WasSuccessful":true,"ValidationErrors":[]}', 200), @@ -110,7 +294,7 @@ void main() { expect( result, - isA().having((r) => r.success, 'success', true), + const CommandSuccess(), ); verify( @@ -120,49 +304,213 @@ void main() { headers: any(named: 'headers'), ), ).called(1); + + verify( + () => logger.info( + 'Command ExampleCommand executed successfully.', + any(), + any(), + ), + ).called(1); }); - test('throws CQRSException on json decoding failure', () async { + test( + 'returns CommandFailure(CommandError.validation) if any validation ' + 'error occured and logs result', () async { + const validationError = ValidationError( + 400, + 'Error message', + 'invalidProperty', + ); + + mockClientPost( + client, + Response( + '{"WasSuccessful":false,"ValidationErrors":[${jsonEncode(validationError)}]}', + 422, + ), + ); + + final result = await cqrs.run(ExampleCommand()); + + expect( + result, + const CommandFailure( + CommandError.validation, + validationErrors: [validationError], + ), + ); + + verify( + () => client.post( + Uri.parse('https://example.org/api/command/ExampleCommand'), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).called(1); + + verify( + () => logger.warning( + 'Command ExampleCommand failed with validation errors:\n' + 'Error message (400), ', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns CommandFailure(CommandError.unknown) on json decoding' + ' failure and logs result', () async { mockClientPost(client, Response('this is not a valid json', 200)); - final result = cqrs.run(ExampleCommand()); + final result = await cqrs.run(ExampleCommand()); expect( result, - throwsA( - isA().having( - (e) => e.message, - 'message', - startsWith('An error occured while decoding response body JSON:'), - ), + const CommandFailure(CommandError.unknown), + ); + + verify( + () => logger.severe( + 'Command ExampleCommand failed while decoding response body JSON.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns CommandFailure(CommandError.network) on socket exception' + ' and logs result', () async { + mockClientException( + client, + const SocketException('This might be socket exception'), + ); + + final result = await cqrs.run(ExampleCommand()); + + expect( + result, + const CommandFailure(CommandError.network), + ); + + verify( + () => logger.severe( + 'Command ExampleCommand failed with network error.', + any(), + any(), ), + ).called(1); + }); + + test( + 'returns CommandFailure(CommandError.unknown) on other' + ' client exceptions and logs result', () async { + mockClientException( + client, + Exception('This is not a socket exception'), ); + + final result = await cqrs.run(ExampleCommand()); + + expect( + result, + const CommandFailure(CommandError.unknown), + ); + + verify( + () => logger.severe( + 'Command ExampleCommand failed unexpectedly.', + any(), + any(), + ), + ).called(1); }); - test('throws CQRSException when response code is other than 200 and 422', - () { - mockClientPost(client, Response('', 500)); + test( + 'returns CommandFailure(CommandError.authentication) when' + ' response code is 401 and logs result', () async { + mockClientPost(client, Response('', 401)); - final result = cqrs.run(ExampleCommand()); + final result = await cqrs.run(ExampleCommand()); expect( result, - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Invalid, non 200 or 422 status code returned ' - 'by ExampleCommand command.', - ), + const CommandFailure(CommandError.authentication), + ); + + verify( + () => logger.severe( + 'Command ExampleCommand failed with authentication error.', + any(), + any(), ), + ).called(1); + }); + + test( + 'returns CommandFailure(CommandError.forbiddenAccess) when' + ' response code is 403 and logs result', () async { + mockClientPost(client, Response('', 403)); + + final result = await cqrs.run(ExampleCommand()); + + expect( + result, + const CommandFailure(CommandError.forbiddenAccess), ); + + verify( + () => logger.severe( + 'Command ExampleCommand failed with forbidden access error.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns CommandFailure(CommandError.unknown) for other' + ' response codes and logs result', () async { + mockClientPost(client, Response('', 404)); + + final result = await cqrs.run(ExampleCommand()); + + expect( + result, + const CommandFailure(CommandError.unknown), + ); + + verify( + () => logger.severe( + 'Command ExampleCommand failed unexpectedly.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'calls CqrsMiddleware.handleCommandResult for each' + ' middleware present', () async { + mockClientPost(client, Response('true', 200)); + + var result = await cqrs.run(ExampleCommand()); + mockCqrsMiddlewareCommandResult(middleware, result); + verifyNever(() => middleware.handleCommandResult(any())); + + cqrs.addMiddleware(middleware); + result = await cqrs.run(ExampleCommand()); + mockCqrsMiddlewareCommandResult(middleware, result); + verify(() => middleware.handleCommandResult(result)).called(1); }); }); group('perform', () { test( - "correctly serializes operation, calls client's send and" - ' deserializes result', () async { + "correctly serializes operation, calls client's send," + ' deserializes result and logs result', () async { mockClientPost(client, Response('true', 200)); final result = await cqrs.perform( @@ -170,7 +518,7 @@ void main() { headers: {'X-Test': 'foobar'}, ); - expect(result, true); + expect(result, const OperationSuccess(true)); verify( () => client.post( @@ -188,6 +536,11 @@ void main() { ), ), ).called(1); + + verify( + () => + logger.info('Operation ExampleOperation executed successfully.'), + ).called(1); }); test('correctly deserializes null operation result', () async { @@ -195,42 +548,158 @@ void main() { final result = await cqrs.perform(ExampleOperation()); - expect(result, null); + expect(result, const OperationSuccess(null)); }); - test('throws CQRSException on json decoding failure', () async { + test( + 'returns OperationFailure(OperationError.unknown) on json decoding' + ' failure and logs result', () async { mockClientPost(client, Response('true', 200)); - final result = cqrs.perform(ExampleOperationFailingResultFactory()); + final result = + await cqrs.perform(ExampleOperationFailingResultFactory()); expect( result, - throwsA( - isA().having( - (e) => e.message, - 'message', - 'An error occured while decoding response body JSON:\n' - 'Exception: This is error.', - ), + const OperationFailure(OperationError.unknown), + ); + + verify( + () => logger.severe( + 'Operation ExampleOperationFailingResultFactory failed while decoding' + ' response body JSON.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns OperationFailure(OperationError.network) on socket exception' + ' and logs result', () async { + mockClientException( + client, + const SocketException('This might be socket exception'), + ); + + final result = await cqrs.perform(ExampleOperation()); + + expect( + result, + const OperationFailure(OperationError.network), + ); + + verify( + () => logger.severe( + 'Operation ExampleOperation failed with network error.', + any(), + any(), ), + ).called(1); + }); + + test( + 'returns OperationFailure(OperationError.unknown) on client exception' + ' and logs result', () async { + final exception = Exception('This is not a socket exception'); + mockClientException(client, exception); + + final result = await cqrs.perform(ExampleOperation()); + + expect( + result, + const OperationFailure(OperationError.unknown), ); + + verify( + () => logger.severe( + 'Operation ExampleOperation failed unexpectedly.', + any(), + any(), + ), + ).called(1); }); - test('throws CQRSException when response code is other than 200', () { - mockClientPost(client, Response('', 404)); + test( + 'returns OperationFailure(OperationError.authentication) when response' + ' code is 401 and logs result', () async { + mockClientPost(client, Response('', 401)); + + final result = await cqrs.perform(ExampleOperation()); + + expect( + result, + const OperationFailure( + OperationError.authentication, + ), + ); - final result = cqrs.perform(ExampleOperation()); + verify( + () => logger.severe( + 'Operation ExampleOperation failed with authentication error.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns OperationFailure(OperationError.forbiddenAccess) when response' + ' code is 403 and logs result', () async { + mockClientPost(client, Response('', 403)); + + final result = await cqrs.perform(ExampleOperation()); expect( result, - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Invalid, non 200 status code returned by ExampleOperation operation.', - ), + const OperationFailure( + OperationError.forbiddenAccess, ), ); + + verify( + () => logger.severe( + 'Operation ExampleOperation failed with forbidden access error.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'returns OperationFailure(OperationError.unknown) for other response' + ' codes and logs result', () async { + mockClientPost(client, Response('', 404)); + + final result = await cqrs.perform(ExampleOperation()); + + expect( + result, + const OperationFailure(OperationError.unknown), + ); + + verify( + () => logger.severe( + 'Operation ExampleOperation failed unexpectedly.', + any(), + any(), + ), + ).called(1); + }); + + test( + 'calls CqrsMiddleware.handleOperationResult for each' + ' middleware present', () async { + mockClientPost(client, Response('true', 200)); + + var result = await cqrs.perform(ExampleOperation()); + mockCqrsMiddlewareOperationResult(middleware, result); + verifyNever(() => middleware.handleOperationResult(any())); + + cqrs.addMiddleware(middleware); + result = await cqrs.perform(ExampleOperation()); + mockCqrsMiddlewareOperationResult(middleware, result); + verify(() => middleware.handleOperationResult(result)).called(1); }); }); }); @@ -246,6 +715,49 @@ void mockClientPost(MockClient client, Response response) { ).thenAnswer((_) async => response); } +void mockClientException(MockClient client, Exception exception) { + when( + () => client.post( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => throw exception); +} + +void mockCqrsMiddlewareQueryResult( + MockCqrsMiddleware middleware, + QueryResult result, +) { + when( + () => middleware.handleQueryResult(result), + ).thenAnswer( + (_) async => Future.value(result), + ); +} + +void mockCqrsMiddlewareCommandResult( + MockCqrsMiddleware middleware, + CommandResult result, +) { + when( + () => middleware.handleCommandResult(result), + ).thenAnswer( + (_) async => Future.value(result), + ); +} + +void mockCqrsMiddlewareOperationResult( + MockCqrsMiddleware middleware, + OperationResult result, +) { + when( + () => middleware.handleOperationResult(result), + ).thenAnswer( + (_) async => Future.value(result), + ); +} + class ExampleQuery implements Query { @override String getFullName() => 'ExampleQuery'; diff --git a/packages/cqrs/test/validation_error_test.dart b/packages/cqrs/test/validation_error_test.dart index a7f7005a..d30b070c 100644 --- a/packages/cqrs/test/validation_error_test.dart +++ b/packages/cqrs/test/validation_error_test.dart @@ -1,4 +1,4 @@ -import 'package:cqrs/src/command_result.dart'; +import 'package:cqrs/src/validation_error.dart'; import 'package:test/test.dart'; void main() { @@ -18,7 +18,7 @@ void main() { }); test('is correctly deserialized from JSON', () { - final error = ValidationError.fromJson({ + final error = ValidationError.fromJson(const { 'ErrorCode': 128, 'ErrorMessage': 'Some other message', 'PropertyName': 'Property', @@ -30,7 +30,7 @@ void main() { }); test('is correctly serialized to JSON', () { - final json = ValidationError.fromJson({ + final json = ValidationError.fromJson(const { 'ErrorCode': 128, 'ErrorMessage': 'Some other message', 'PropertyName': 'Property',