diff --git a/rollbar_common/lib/rollbar_common.dart b/rollbar_common/lib/rollbar_common.dart index fa9ef4d..8a514d9 100644 --- a/rollbar_common/lib/rollbar_common.dart +++ b/rollbar_common/lib/rollbar_common.dart @@ -21,3 +21,4 @@ export 'src/data/breadcrumb_record.dart'; export 'src/http.dart'; export 'src/level.dart'; export 'src/tuple.dart'; +export 'src/result.dart'; diff --git a/rollbar_common/lib/src/data/breadcrumb_record.dart b/rollbar_common/lib/src/data/breadcrumb_record.dart index 6753c5a..bc349ee 100644 --- a/rollbar_common/lib/src/data/breadcrumb_record.dart +++ b/rollbar_common/lib/src/data/breadcrumb_record.dart @@ -14,9 +14,11 @@ import '../../rollbar_common.dart' class BreadcrumbRecord implements Persistable { @override final UUID id; - final String breadcrumb; + @override final DateTime timestamp; + final String breadcrumb; + static Map get persistingKeyTypes => { 'id': Datatype.uuid, 'breadcrumb': Datatype.text, diff --git a/rollbar_common/lib/src/data/payload_record.dart b/rollbar_common/lib/src/data/payload_record.dart index 3356869..72dcae2 100644 --- a/rollbar_common/lib/src/data/payload_record.dart +++ b/rollbar_common/lib/src/data/payload_record.dart @@ -14,10 +14,12 @@ import '../../rollbar_common.dart' class PayloadRecord implements Persistable { @override final UUID id; + @override + final DateTime timestamp; + final String accessToken; final String endpoint; final String payload; - final DateTime timestamp; static Map get persistingKeyTypes => { 'id': Datatype.uuid, diff --git a/rollbar_common/lib/src/http.dart b/rollbar_common/lib/src/http.dart index 01fdea2..e94c545 100644 --- a/rollbar_common/lib/src/http.dart +++ b/rollbar_common/lib/src/http.dart @@ -1,7 +1,30 @@ +import 'package:http/http.dart' as http; + enum HttpMethod { get, head, post, put, delete, connect, options, trace, patch } +enum HttpStatus { info, success, redirect, clientError, serverError } + typedef HttpHeaders = Map; extension HttpMethodName on HttpMethod { String get name => toString().split('.').last.toUpperCase(); } + +extension HttpResponseExtension on http.Response { + /// The HTTP status for this response derived from the status code. + HttpStatus get status { + if (statusCode >= 100 && statusCode < 200) { + return HttpStatus.info; + } else if (statusCode >= 200 && statusCode < 300) { + return HttpStatus.success; + } else if (statusCode >= 300 && statusCode < 400) { + return HttpStatus.redirect; + } else if (statusCode >= 400 && statusCode < 500) { + return HttpStatus.clientError; + } else if (statusCode >= 500 && statusCode < 600) { + return HttpStatus.serverError; + } else { + throw StateError('http status code $statusCode is invalid.'); + } + } +} diff --git a/rollbar_common/lib/src/identifiable.dart b/rollbar_common/lib/src/identifiable.dart index d035515..0e86868 100644 --- a/rollbar_common/lib/src/identifiable.dart +++ b/rollbar_common/lib/src/identifiable.dart @@ -7,6 +7,19 @@ const uuidGen = Uuid(); typedef UUID = UuidValue; +final nilUUID = UUID("00000000-0000-0000-0000-000000000000"); + abstract class Identifiable { T get id; } + +extension IterableIntoUUID on Iterable { + UUID toUUID() => UUID.fromList(toList()); +} + +extension StringIntoUUID on String { + UUID toUUID() => RegExp(r'\w\w') + .allMatches(this) + .map((match) => int.parse(match[0]!, radix: 16)) + .toUUID(); +} diff --git a/rollbar_common/lib/src/persistable.dart b/rollbar_common/lib/src/persistable.dart index 0f29059..7384f74 100644 --- a/rollbar_common/lib/src/persistable.dart +++ b/rollbar_common/lib/src/persistable.dart @@ -25,6 +25,8 @@ extension DatatypeSqlType on Datatype { /// `(key, value)` pairs through [Serializable.fromMap] and [toMap]. abstract class Persistable implements Serializable, Comparable>, Identifiable { + DateTime get timestamp; + static const _map = { Persistable: PersistableFor(), PayloadRecord: PersistablePayloadRecord(), diff --git a/rollbar_common/lib/src/result.dart b/rollbar_common/lib/src/result.dart new file mode 100644 index 0000000..4e90d1d --- /dev/null +++ b/rollbar_common/lib/src/result.dart @@ -0,0 +1,51 @@ +import 'package:meta/meta.dart'; + +abstract class Result { + bool get isSuccess => this is Success; + bool get isFailure => this is Failure; + + T get success => (this as Success).value; + E get failure => (this as Failure).error; + + Result map(U Function(T) transform) => isSuccess + ? Success(transform((this as Success).value)) + : Failure((this as Failure).error); +} + +@sealed +@immutable +class Success extends Result { + final T value; + + Success(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Success && other.value == value); + + @override + int get hashCode => value.hashCode; + + @override + String toString() => 'Result.Success($value)'; +} + +@sealed +@immutable +class Failure extends Result { + final E error; + + Failure(this.error); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Failure && other.error == error); + + @override + int get hashCode => error.hashCode; + + @override + String toString() => 'Result.Failure($error)'; +} diff --git a/rollbar_common/lib/src/tuple.dart b/rollbar_common/lib/src/tuple.dart index 36e6cfa..5e4e887 100644 --- a/rollbar_common/lib/src/tuple.dart +++ b/rollbar_common/lib/src/tuple.dart @@ -1,11 +1,16 @@ import 'package:meta/meta.dart'; +import 'zipped.dart'; + @sealed @immutable class Tuple2 { final T1 first; final T2 second; + T1 get $1 => first; + T2 get $2 => second; + const Tuple2(this.first, this.second); factory Tuple2.empty() => Tuple2(null as T1, null as T2); @@ -22,6 +27,17 @@ class Tuple2 { } } + // Takes two nullables and returns a nullable of the corresponding pair. + static Tuple2? zip(A? first, B? second) => + first != null && second != null ? Tuple2(first, second) : null; + + // Takes two iterables and returns a single iterable of corresponding pairs. + static Iterable> zipIt( + Iterable first, + Iterable second, + ) => + ZippedIterable(first, second); + Tuple2 mapFirst(U Function(T1) f) => Tuple2(f(first), second); Tuple2 mapSecond(U Function(T2) f) => Tuple2(first, f(second)); diff --git a/rollbar_common/pubspec.yaml b/rollbar_common/pubspec.yaml index d737358..62dff3d 100644 --- a/rollbar_common/pubspec.yaml +++ b/rollbar_common/pubspec.yaml @@ -9,6 +9,7 @@ environment: sdk: '>=2.17.0 <3.0.0' dependencies: + http: ^0.13.0 collection: ^1.16.0 sqlite3: ^1.7.0 uuid: ^3.0.0 diff --git a/rollbar_dart/lib/src/config.dart b/rollbar_dart/lib/src/config.dart index 413ec8c..15b8d21 100644 --- a/rollbar_dart/lib/src/config.dart +++ b/rollbar_dart/lib/src/config.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:http/http.dart' as http; import 'package:rollbar_common/rollbar_common.dart'; import '../rollbar.dart'; @@ -33,6 +34,7 @@ class Config implements Serializable { final Wrangler Function(Config) wrangler; final Transformer Function(Config) transformer; final Sender Function(Config) sender; + final http.Client Function() httpClient; const Config({ required this.accessToken, @@ -49,6 +51,7 @@ class Config implements Serializable { this.wrangler = DataWrangler.new, this.transformer = NoopTransformer.new, this.sender = PersistentHttpSender.new, + this.httpClient = http.Client.new, }); Config copyWith({ diff --git a/rollbar_dart/lib/src/data/response.dart b/rollbar_dart/lib/src/data/response.dart index 0e73335..6a54899 100644 --- a/rollbar_dart/lib/src/data/response.dart +++ b/rollbar_dart/lib/src/data/response.dart @@ -4,60 +4,41 @@ import 'package:meta/meta.dart'; import 'package:http/http.dart' as http; import 'package:rollbar_common/rollbar_common.dart'; -/// Represents the response from the Rollbar API. -/// -/// Rollbar will respond with either an error [message] xor a [Result]. @sealed @immutable -class Response - with EquatableSerializableMixin - implements Serializable, Equatable { - final int error; - final String? message; - final UUID? result; - - Response({this.error = 0, this.message, this.result}) { - if (error == 0 && message == null) { - ArgumentError.checkNotNull(result, 'result'); - } - } - - bool get isError => error != 0; - - Response copyWith({int? error, String? message, UUID? result}) => Response( - error: error ?? this.error, - message: message ?? this.message, - result: result ?? this.result); - - @override - JsonMap toMap() => { - 'err': error, - 'message': message, - 'result': {'uuid': result?.uuid}.compact() - }.compact(); - - factory Response.fromMap(JsonMap map) => - Response(error: map.error, message: map.message, result: map.uuid); +class ResponseError extends Error { + final int code; + final String message; - factory Response.from(http.Response response) => - Response.fromMap(jsonDecode(response.body)); + ResponseError(this.code, this.message); @override - String toString() => - 'Response(error: $error, message: $message, result: $result)'; + String toString() => '$code $message'; } -extension _Attributes on JsonMap { - int get error => this['err']?.toInt() ?? 0; - - String? get message => this['message']; +/// Represents the response from the Rollbar API. +/// +/// Rollbar will respond with either an error [message] xor a [UUID]. +extension APIResponse on http.Response { + Result get result => // + status == HttpStatus.success + ? Success(body.uuid) + : Failure(ResponseError( + body.error ?? statusCode, + body.reason ?? reasonPhrase ?? status.name, + )); +} - UUID? get uuid { - final uuid = this['result']?['uuid'] as String?; - final byteList = uuid - .map(RegExp(r'\w\w').allMatches) - ?.map((match) => int.parse(match[0]!, radix: 16)) - .toList(); - return byteList.map(UUID.fromList); +extension _Attributes on String { + JsonMap get body { + try { + return jsonDecode(this); + } catch (_) { + return {}; + } } + + int? get error => body['err']; + String? get reason => body['message']; + UUID get uuid => (body['result']['uuid'] as String?)?.toUUID() ?? nilUUID; } diff --git a/rollbar_dart/lib/src/persistence.dart b/rollbar_dart/lib/src/persistence.dart index 16b8900..92dca28 100644 --- a/rollbar_dart/lib/src/persistence.dart +++ b/rollbar_dart/lib/src/persistence.dart @@ -31,4 +31,7 @@ mixin Persistence> implements Configurable { return _database as Database; }()); + + bool didExpire(Record record) => + record.timestamp < DateTime.now().toUtc() - config.persistenceLifetime; } diff --git a/rollbar_dart/lib/src/sender/http_sender.dart b/rollbar_dart/lib/src/sender/http_sender.dart index 1ddbed0..6dff84a 100644 --- a/rollbar_dart/lib/src/sender/http_sender.dart +++ b/rollbar_dart/lib/src/sender/http_sender.dart @@ -3,34 +3,29 @@ import 'dart:developer'; import 'dart:io'; import 'package:meta/meta.dart'; -import 'package:http/http.dart' as http; import 'package:rollbar_common/rollbar_common.dart'; -import '../data/response.dart'; +import '../config.dart'; import 'sender.dart'; /// HTTP [Sender] implementation. @sealed @immutable @internal -class HttpSender implements Sender { - final Uri _endpoint; - final HttpHeaders _headers; +class HttpSender implements Configurable, Sender { + @override + final Config config; + final HttpHeaders headers; + final Uri uri; - HttpSender({required String accessToken, required String endpoint}) - : _endpoint = Uri.parse(endpoint), - _headers = { + HttpSender(this.config) + : uri = Uri.parse(config.endpoint), + headers = { 'User-Agent': 'rollbar-dart', 'Content-Type': 'application/json', - 'X-Rollbar-Access-Token': accessToken, + 'X-Rollbar-Access-Token': config.accessToken, }; - static Future sendRecord(PayloadRecord record) async => - await HttpSender( - endpoint: record.endpoint, - accessToken: record.accessToken, - ).sendString(record.payload); - /// Sends the provided payload as the body of POST request to the configured /// endpoint. @override @@ -38,50 +33,30 @@ class HttpSender implements Sender { @override Future sendString(String payload) async { - try { - if (_State.suspended) { - throw HttpException( - 'HTTP transactions are currently suspended.', - uri: _endpoint, - ); - } + final client = config.httpClient(); - final response = await http - .post(_endpoint, headers: _headers, body: payload) - .then(Response.from); + try { + final response = await client.post(uri, headers: headers, body: payload); - if (response.isError) { + if (response.status != HttpStatus.success) { throw HttpException( - '${response.error}: ${response.message}', - uri: _endpoint, + response.reasonPhrase ?? response.status.name, + uri: uri, ); } return true; } catch (error, stackTrace) { - if (!_State.suspended) _State.suspend(30.seconds); - - log('Exception sending payload', + log('Error sending payload to \'$uri\'', + name: 'Rollbar.${runtimeType.toString()}', time: DateTime.now(), level: Level.error.value, - name: runtimeType.toString(), error: error, stackTrace: stackTrace); return false; + } finally { + client.close(); } } } - -extension _State on HttpSender { - static bool _suspended = false; - - static bool get suspended => _suspended; - - static void suspend(Duration duration) async { - if (_suspended) return; - _suspended = true; - await Future.delayed(duration); - _suspended = false; - } -} diff --git a/rollbar_dart/lib/src/sender/persistent_http_sender.dart b/rollbar_dart/lib/src/sender/persistent_http_sender.dart index 5375dcb..07033ef 100644 --- a/rollbar_dart/lib/src/sender/persistent_http_sender.dart +++ b/rollbar_dart/lib/src/sender/persistent_http_sender.dart @@ -1,9 +1,10 @@ -import 'dart:convert'; +import 'dart:developer' as developer; import 'package:meta/meta.dart'; +import 'package:http/http.dart' as http; import 'package:rollbar_common/rollbar_common.dart'; -import '../config.dart'; +import '../data/response.dart'; import '../persistence.dart'; import 'sender.dart'; import 'http_sender.dart'; @@ -13,36 +14,91 @@ import 'http_sender.dart'; @sealed @immutable @internal -class PersistentHttpSender - with Persistence - implements Configurable, Sender { - @override - final Config config; - - PersistentHttpSender(this.config); - - @override - Future send(JsonMap payload) async => sendString(jsonEncode(payload)); +class PersistentHttpSender extends HttpSender with Persistence { + PersistentHttpSender(super.config); @override Future sendString(String payload) async { - records.add(PayloadRecord( + final newRecord = PayloadRecord( accessToken: config.accessToken, endpoint: config.endpoint, payload: payload, - )); + ); + + records.add(newRecord); + records.where(didExpire).forEach(records.remove); + + if (_State.suspended) return false; + + final httpClient = config.httpClient(); + + try { + for (final record in records) { + final response = await httpClient.post( + uri, + headers: headers, + body: record.payload, + ); - for (final record in records) { - final success = await HttpSender.sendRecord(record); - final expiration = DateTime.now().toUtc() - config.persistenceLifetime; + if (response.status == HttpStatus.success) { + records.remove(record); + continue; + } - if (success || record.timestamp < expiration) { - records.remove(record); + log(response); + switch (response.statusCode) { + case 413: // Payload Too Large + case 422: // Unprocessable Entity + records.remove(record); + break; + case 429: // Too Many Requests + case 500: // Internal Server Error + case 501: // Not Implemented + case 502: // Bad Gateway + case 503: // Service Unavailable + case 504: // Gateway Timeout + _State.suspend(30.seconds); + return false; + } } - if (!success) return false; + return !records.contains(newRecord); + } on http.ClientException catch (error, stackTrace) { + log(error, stackTrace); + return false; + } finally { + httpClient.close(); } + } + + void log(Object o, [StackTrace? stackTrace]) { + if (o is http.Response) { + developer.log( + '\'${o.statusCode} ${o.reasonPhrase}\' sending payload to \'$uri\'', + name: 'Rollbar.${runtimeType.toString()}', + time: DateTime.now(), + level: Level.error.value, + error: o.result.failure, + stackTrace: StackTrace.current); + } else if (o is http.ClientException) { + developer.log('${o.message} while trying to reach \'${o.uri}\'', + name: 'Rollbar.${runtimeType.toString()}', + time: DateTime.now(), + level: Level.critical.value, + error: o, + stackTrace: stackTrace); + } + } +} + +extension _State on HttpSender { + static bool _suspended = false; + + static bool get suspended => _suspended; - return true; + static void suspend(Duration duration) async { + if (_suspended) return; + _suspended = true; + _suspended = await Future.delayed(duration).then((_) => false); } } diff --git a/rollbar_dart/test/utils/client_server_utils.dart b/rollbar_dart/test/client_server.utils.dart similarity index 100% rename from rollbar_dart/test/utils/client_server_utils.dart rename to rollbar_dart/test/client_server.utils.dart diff --git a/rollbar_dart/test/data/response_test.dart b/rollbar_dart/test/data/response_test.dart deleted file mode 100644 index c6726af..0000000 --- a/rollbar_dart/test/data/response_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:convert'; - -import 'package:rollbar_common/rollbar_common.dart'; -import 'package:rollbar_dart/src/data/response.dart'; -import 'package:test/test.dart'; - -void main() { - group('Response Serialization tests', () { - test('Json roundtrip serialization test', () { - final response = Response( - error: 1, - message: 'error', - result: UUID('67ce3d7b-fab1-4fd9-9218-ae5c985071e7')); - - final asJson = jsonEncode(response.toMap()); - final map = jsonDecode(asJson); - final recovered = Response.fromMap(map); - - expect(recovered.error, equals(response.error)); - expect(recovered.message, equals(response.message)); - expect(recovered.result, equals(response.result)); - }); - - test('Serialization is null-safe test', () { - final response = Response( - error: 0, - message: null, - result: UUID('67ce3d7b-fab1-4fd9-9218-ae5c985071e7')); - final asJson = jsonEncode(response.toMap()); - final recovered = Response.fromMap(jsonDecode(asJson)); - - expect(recovered.error, equals(response.error)); - expect(recovered.message, equals(response.message)); - expect(recovered.result, equals(response.result)); - - final response2 = Response(error: 1, message: '', result: null); - final asJson2 = jsonEncode(response2.toMap()); - final recovered2 = Response.fromMap(jsonDecode(asJson2)); - - expect(recovered2.error, equals(response2.error)); - expect(recovered2.message, equals(response2.message)); - expect(recovered2.result, equals(response2.result)); - }); - }); -} diff --git a/rollbar_dart/test/http_sender_test.dart b/rollbar_dart/test/http_sender_test.dart deleted file mode 100644 index 751bbd4..0000000 --- a/rollbar_dart/test/http_sender_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:rollbar_dart/src/data/response.dart'; -import 'package:test/test.dart'; -import 'package:mockito/mockito.dart'; -import 'package:mockito/annotations.dart'; - -import 'http_sender_test.mocks.dart'; - -@GenerateMocks([http.Response]) -void main() { - group('Response conversion', () { - test('Can convert successful API response', () async { - final response = MockResponse(); - when(response.body).thenReturn('''{ - "err": 0, - "result": { - "id": null, - "uuid": "67ce3d7bfab14fd99218ae5c985071e7" - } -}'''); - final rollbarResponse = Response.from(response); - - expect(rollbarResponse.error, equals(0)); - expect(rollbarResponse.result, isNotNull); - expect(rollbarResponse.result!.uuid, - equals('67ce3d7b-fab1-4fd9-9218-ae5c985071e7')); - }); - - test('Can convert error API response', () async { - final response = MockResponse(); - when(response.body).thenReturn('''{ - "err": 1, - "message": "invalid token" -}'''); - final rollbarResponse = Response.from(response); - - expect(rollbarResponse.error, equals(1)); - expect(rollbarResponse.message, equals('invalid token')); - expect(rollbarResponse.result, isNull); - }); - }); -} diff --git a/rollbar_dart/test/http_sender_test.mocks.dart b/rollbar_dart/test/http_sender_test.mocks.dart deleted file mode 100644 index 99df19a..0000000 --- a/rollbar_dart/test/http_sender_test.mocks.dart +++ /dev/null @@ -1,50 +0,0 @@ -// Mocks generated by Mockito 5.0.7 from annotations -// in rollbar_dart/test/http_sender_test.dart. -// Do not manually edit this file. - -import 'dart:typed_data' as i2; - -import 'package:http/src/response.dart' as i3; -import 'package:mockito/mockito.dart' as i1; - -// ignore_for_file: comment_references -// ignore_for_file: unnecessary_parenthesis - -// ignore_for_file: prefer_const_constructors - -// ignore_for_file: avoid_redundant_argument_values - -class _FakeUint8List extends i1.Fake {} - -/// A class which mocks [Response]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockResponse extends i1.Mock implements i3.Response { - MockResponse() { - i1.throwOnMissingStub(this); - } - - @override - i2.Uint8List get bodyBytes => - (super.noSuchMethod(Invocation.getter(#bodyBytes), - returnValue: _FakeUint8List()) as i2.Uint8List); - @override - String get body => - (super.noSuchMethod(Invocation.getter(#body), returnValue: '') as String); - @override - int get statusCode => - (super.noSuchMethod(Invocation.getter(#statusCode), returnValue: 0) - as int); - @override - Map get headers => - (super.noSuchMethod(Invocation.getter(#headers), - returnValue: {}) as Map); - @override - bool get isRedirect => - (super.noSuchMethod(Invocation.getter(#isRedirect), returnValue: false) - as bool); - @override - bool get persistentConnection => - (super.noSuchMethod(Invocation.getter(#persistentConnection), - returnValue: false) as bool); -} diff --git a/rollbar_dart/test/sender/response_test.dart b/rollbar_dart/test/sender/response_test.dart new file mode 100644 index 0000000..0da38b7 --- /dev/null +++ b/rollbar_dart/test/sender/response_test.dart @@ -0,0 +1,86 @@ +import 'package:meta/meta.dart'; +import 'package:test/test.dart'; +import 'package:http/http.dart'; + +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; + +import 'package:rollbar_common/src/http.dart'; +import 'package:rollbar_common/src/identifiable.dart'; +import 'package:rollbar_dart/src/data/response.dart'; + +import 'response_test.mocks.dart'; + +@GenerateMocks([Response]) +void main() { + group('API Response representation', () { + test('Can represent an API success response', () { + const statusCode = 200, reason = 'OK'; + + final response = MockResponse(); + when(response.statusCode).thenReturn(statusCode); + when(response.reasonPhrase).thenReturn(reason); + when(response.body).thenReturn(Body.success); + + expect(response.statusCode, statusCode); + expect(response.status, HttpStatus.success); + expect(response.body, Body.success); + expect(response.result.isSuccess, isTrue); + expect(response.result.isFailure, isFalse); + expect(response.result.success, Body.uuidString.toUUID()); + }); + + test('Can represent an API failure response', () { + const statusCode = 422, reason = 'Unprocessable Entity'; + + final response = MockResponse(); + when(response.statusCode).thenReturn(statusCode); + when(response.reasonPhrase).thenReturn(reason); + when(response.body).thenReturn(Body.failure); + + expect(response.statusCode, statusCode); + expect(response.status, HttpStatus.clientError); + expect(response.body, Body.failure); + expect(response.result.isSuccess, isFalse); + expect(response.result.isFailure, isTrue); + expect(response.result.failure.code, 1); + expect(response.result.failure.message, 'invalid format'); + }); + + test('Can represent an HTTP failure response', () { + const statusCode = 503, reason = 'Service Unavailable'; + + final response = MockResponse(); + when(response.statusCode).thenReturn(statusCode); + when(response.reasonPhrase).thenReturn(reason); + when(response.body).thenReturn(Body.empty); + + expect(response.statusCode, statusCode); + expect(response.status, HttpStatus.serverError); + expect(response.body, Body.empty); + expect(response.result.isSuccess, isFalse); + expect(response.result.isFailure, isTrue); + expect(response.result.failure.code, statusCode); + expect(response.result.failure.message, reason); + }); + }); +} + +@internal +class Body { + static String uuidString = '67ce3d7bfab14fd99218ae5c985071e7'; + + static String empty = ''; + + static String success = '''{ + "err": 0, + "result": { + "uuid": "$uuidString" + } + }'''; + + static String failure = '''{ + "err": 1, + "message": "invalid format" + }'''; +} diff --git a/rollbar_dart/test/sender/response_test.mocks.dart b/rollbar_dart/test/sender/response_test.mocks.dart new file mode 100644 index 0000000..f486a3c --- /dev/null +++ b/rollbar_dart/test/sender/response_test.mocks.dart @@ -0,0 +1,60 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in rollbar_dart/test/sender/response_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:typed_data' as _i3; + +import 'package:http/src/response.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [Response]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockResponse extends _i1.Mock implements _i2.Response { + MockResponse() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Uint8List get bodyBytes => (super.noSuchMethod( + Invocation.getter(#bodyBytes), + returnValue: _i3.Uint8List(0), + ) as _i3.Uint8List); + @override + String get body => (super.noSuchMethod( + Invocation.getter(#body), + returnValue: '', + ) as String); + @override + int get statusCode => (super.noSuchMethod( + Invocation.getter(#statusCode), + returnValue: 0, + ) as int); + @override + Map get headers => (super.noSuchMethod( + Invocation.getter(#headers), + returnValue: {}, + ) as Map); + @override + bool get isRedirect => (super.noSuchMethod( + Invocation.getter(#isRedirect), + returnValue: false, + ) as bool); + @override + bool get persistentConnection => (super.noSuchMethod( + Invocation.getter(#persistentConnection), + returnValue: false, + ) as bool); +} diff --git a/rollbar_dart/test/sender/sender_test.dart b/rollbar_dart/test/sender/sender_test.dart new file mode 100644 index 0000000..9fd1432 --- /dev/null +++ b/rollbar_dart/test/sender/sender_test.dart @@ -0,0 +1,135 @@ +import 'package:test/test.dart'; +import 'package:http/http.dart' as http; + +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; + +import 'package:rollbar_dart/src/config.dart'; +import 'package:rollbar_dart/src/sender/http_sender.dart'; +import 'package:rollbar_dart/src/sender/persistent_http_sender.dart'; + +import 'sender_test.mocks.dart'; +import 'sender_test.utils.dart'; + +@GenerateMocks([http.Client]) +Future main() async { + group('HTTP transport via Senders', () { + test('HTTP Sender posts appropriately and succeeds', () async { + final client = MockClient(); + final config = Config(accessToken: '012345678', httpClient: () => client); + final expected = Expected(config); + final sender = HttpSender(config); + + when(client.post( + any, + headers: anyNamed('headers'), + body: anyNamed('body'), + )).thenAnswer( + (_) async => http.Response(expected.successBody, 200), + ); + + expect(await sender.sendString(expected.payload), isTrue); + + verifyInOrder([ + client.post( + expected.endpoint, + headers: expected.headers, + body: expected.payload, + ), + client.close(), + ]); + + verifyNoMoreInteractions(client); + }); + + test('HTTP Sender posts appropriately and fails', () async { + final client = MockClient(); + final config = Config(accessToken: '012345678', httpClient: () => client); + final expected = Expected(config); + final sender = HttpSender(config); + + when(client.post( + any, + headers: anyNamed('headers'), + body: anyNamed('body'), + )).thenAnswer( + (_) async => http.Response(expected.failureBody, 422), + ); + + expect(await sender.sendString(expected.payload), isFalse); + + verifyInOrder([ + client.post( + expected.endpoint, + headers: expected.headers, + body: expected.payload, + ), + client.close(), + ]); + + verifyNoMoreInteractions(client); + }); + }); + + group('Persistent HTTP transport via Senders', () { + test('Persistent HTTP Sender posts appropriately and succeeds', () async { + final client = MockClient(); + final config = Config(accessToken: '012345678', httpClient: () => client); + final expected = Expected(config); + final sender = PersistentHttpSender(config); + + when(client.post( + any, + headers: anyNamed('headers'), + body: anyNamed('body'), + )).thenAnswer( + (_) async => http.Response(expected.successBody, 200), + ); + + expect(await sender.sendString(expected.payload), isTrue); + expect(sender.records.isEmpty, isTrue); + + verifyInOrder([ + client.post( + expected.endpoint, + headers: expected.headers, + body: expected.payload, + ), + client.close(), + ]); + + verifyNoMoreInteractions(client); + }); + + test('Persistent HTTP Sender posts but server is unavailable', () async { + final client = MockClient(); + final config = Config(accessToken: '012345678', httpClient: () => client); + final expected = Expected(config); + final sender = PersistentHttpSender(config); + + when(client.post( + any, + headers: anyNamed('headers'), + body: anyNamed('body'), + )).thenAnswer( + (_) async => http.Response(expected.emptyBody, 503), + ); + + expect(await sender.sendString(expected.payload), isFalse); + expect(sender.records.isEmpty, isFalse); + sender.records.clear(); + expect(sender.records.isEmpty, isTrue); + + verifyInOrder([ + client.post( + expected.endpoint, + headers: expected.headers, + body: expected.payload, + ), + client.close(), + ]); + + verifyNoMoreInteractions(client); + }); + }); +} diff --git a/rollbar_dart/test/sender/sender_test.mocks.dart b/rollbar_dart/test/sender/sender_test.mocks.dart new file mode 100644 index 0000000..aee239e --- /dev/null +++ b/rollbar_dart/test/sender/sender_test.mocks.dart @@ -0,0 +1,263 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in rollbar_dart/test/sender/sender_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i5; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + MockClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + @override + _i3.Future read( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future.value(''), + ) as _i3.Future); + @override + _i3.Future<_i5.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i5.Uint8List>.value(_i5.Uint8List(0)), + ) as _i3.Future<_i5.Uint8List>); + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method( + #send, + [request], + ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), + ) as _i3.Future<_i2.StreamedResponse>); + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/rollbar_dart/test/sender/sender_test.utils.dart b/rollbar_dart/test/sender/sender_test.utils.dart new file mode 100644 index 0000000..c7347cd --- /dev/null +++ b/rollbar_dart/test/sender/sender_test.utils.dart @@ -0,0 +1,76 @@ +import 'package:rollbar_common/src/http.dart'; +import 'package:rollbar_dart/src/config.dart'; + +class Expected { + static String uuidString = '67ce3d7bfab14fd99218ae5c985071e7'; + + final Config config; + + const Expected(this.config); + + Uri get endpoint => Uri.parse(config.endpoint); + + String get successBody => '''{ + "err": 0, + "result": { + "uuid": "$uuidString" + } + }'''; + + String get failureBody => '''{ + "err": 1, + "message": "invalid format" + }'''; + + String get emptyBody => ''; + + HttpHeaders get headers => { + 'User-Agent': 'rollbar-dart', + 'Content-Type': 'application/json', + 'X-Rollbar-Access-Token': config.accessToken, + }; + + String get payload => '''{ + "data":{ + "body":{ + "telemetry":[{ + "type":"navigation", + "level":"info", + "source":"client", + "timestamp_ms":1668254707064529, + "body":{ + "from":"initialize", + "to":"runApp" + } + }], + "message":{ + "body":"Rollbar initialized" + } + }, + "notifier":{ + "version":"1.0.0", + "name":"rollbar-dart" + }, + "environment":"development", + "client":{ + "locale":"und", + "hostname":"Hetfield", + "os":"ios", + "os_version":"Version 16.1 (Build 20B72)", + "dart":{ + "version":"2.18.4 (stable) (Tue Nov 1 15:15:07 2022 +0000) on \\"ios_x64\\"" + }, + "number_of_processors":10 + }, + "platform":"ios", + "language":"dart", + "level":"info", + "timestamp":1668254707501008, + "server":{ + "root":"rollbar_flutter_example" + }, + "framework":"flutter", + "code_version":"main" + } + }'''; +}