diff --git a/test/raygun4flutter_test.dart b/test/raygun4flutter_test.dart index f1fb2ca..be857e8 100644 --- a/test/raygun4flutter_test.dart +++ b/test/raygun4flutter_test.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:raygun4flutter/raygun4flutter.dart'; import 'package:raygun4flutter/src/logging/raygun_logger.dart'; +import 'package:raygun4flutter/src/messages/raygun_error_message.dart'; import 'package:raygun4flutter/src/messages/raygun_message.dart'; import 'package:raygun4flutter/src/services/crash_reporting_device.dart'; import 'package:raygun4flutter/src/services/settings.dart'; @@ -16,13 +17,23 @@ import 'package:raygun4flutter/src/services/settings.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); dynamic capturedBody; + Uri? capturedUrl; + int callCount = 0; setUp(() async { RaygunLogger.testMode = true; capturedBody = null; + capturedUrl = null; + callCount = 0; Settings.skipIfTest = true; + Settings.tags = null; + Settings.customData = null; + Settings.breadcrumbs = []; + Settings.crashReportingEndpoint = Settings.kDefaultCrashReportingEndpoint; Settings.customHttpClient = MockClient((request) async { capturedBody = jsonDecode(request.body); + capturedUrl = request.url; + callCount += 1; print(capturedBody); return http.Response('', 204); }); @@ -242,6 +253,294 @@ void main() { files = await CrashReportingPostService.getCachedFiles(); expect(files.length, 0); }); + + test('global tags merge with per-call tags', () async { + await Raygun.setTags(['global1', 'global2']); + await Raygun.sendException(error: Exception('MESSAGE'), tags: ['call1']); + expect(capturedBody['details']['tags'], ['call1', 'global1', 'global2']); + }); + + test('setTags(null) clears global tags', () async { + await Raygun.setTags(['global']); + await Raygun.setTags(null); + await Raygun.sendException(error: Exception('MESSAGE')); + expect(capturedBody['details']['tags'], []); + }); + + test('global customData merges with per-call customData', () async { + await Raygun.setCustomData({'global': 'value', 'shared': 'global'}); + await Raygun.sendException( + error: Exception('MESSAGE'), + customData: {'call': 1, 'shared': 'call'}, + ); + expect(capturedBody['details']['userCustomData'], { + 'call': 1, + // global value overwrites per-call when keys collide (addAll semantics) + 'shared': 'global', + 'global': 'value', + }); + }); + + test('setUser with full RaygunUserInfo', () async { + final user = RaygunUserInfo( + identifier: 'user-42', + email: 'jane@example.com', + firstName: 'Jane', + fullName: 'Jane Doe', + ); + await Raygun.setUser(user); + await Raygun.sendException(error: Exception('MESSAGE')); + final sentUser = capturedBody['details']['user'] as Map; + expect(sentUser['identifier'], 'user-42'); + expect(sentUser['email'], 'jane@example.com'); + expect(sentUser['firstName'], 'Jane'); + expect(sentUser['fullName'], 'Jane Doe'); + expect(sentUser['isAnonymous'], false); + }); + + test('setUser(null) resets to anonymous user', () async { + await Raygun.setUser( + RaygunUserInfo(identifier: 'someone', email: 'a@b.com'), + ); + await Raygun.setUser(null); + expect(Settings.userInfo.isAnonymous, isTrue); + expect(Settings.userInfo.email, isNull); + }); + + test('setCustomCrashReportingEndpoint changes request URL', () async { + await Raygun.setCustomCrashReportingEndpoint( + 'https://custom.example.com/entries', + ); + await Raygun.sendException(error: Exception('MESSAGE')); + expect(capturedUrl.toString(), 'https://custom.example.com/entries'); + }); + + test('setCustomCrashReportingEndpoint(null) restores default', () async { + await Raygun.setCustomCrashReportingEndpoint( + 'https://custom.example.com/entries', + ); + await Raygun.setCustomCrashReportingEndpoint(null); + await Raygun.sendException(error: Exception('MESSAGE')); + expect(capturedUrl.toString(), Settings.kDefaultCrashReportingEndpoint); + }); + + test('clearBreadcrumbs empties the breadcrumb buffer', () async { + Raygun.recordBreadcrumb('a'); + Raygun.recordBreadcrumb('b'); + expect(Settings.breadcrumbs.length, 2); + await Raygun.clearBreadcrumbs(); + expect(Settings.breadcrumbs, isEmpty); + }); + + test('RaygunBreadcrumbLevel round-trips as string for every value', () { + for (final level in RaygunBreadcrumbLevel.values) { + final original = RaygunBreadcrumbMessage( + message: 'msg', + level: level, + timestamp: 1234567890, + ); + final json = original.toJson(); + // levels must be serialised as strings, not ints + expect(json['level'], isA()); + expect(json['level'], level.name); + + // round-trip back through fromJson + final decoded = RaygunBreadcrumbMessage.fromJson(json); + expect(decoded.level, level); + expect(decoded.message, 'msg'); + expect(decoded.timestamp, 1234567890); + } + }); + + test('sendAllStored happy path re-sends and clears the cache', () async { + await _deleteOldFiles(); + + final service = CrashReportingPostService( + client: Settings.customHttpClient, + ); + final raygunMessage = RaygunMessage(); + await service.store(raygunMessage.toJson()); + await service.store(raygunMessage.toJson()); + + var files = await CrashReportingPostService.getCachedFiles(); + expect(files.length, 2); + + callCount = 0; + await service.sendAllStored('KEY'); + + // every cached payload should have been re-posted + expect(callCount, 2); + + // cache should be empty after a successful flush + files = await CrashReportingPostService.getCachedFiles(); + expect(files, isEmpty); + }); + + test('offline path stores instead of sending', () async { + await _deleteOldFiles(); + Settings.getConnectivityState = () async => [ConnectivityResult.none]; + + await Raygun.sendException(error: Exception('MESSAGE')); + + // MockClient must not have been hit + expect(callCount, 0); + expect(capturedBody, isNull); + + // payload should have been written to disk instead + final files = await CrashReportingPostService.getCachedFiles(); + expect(files.length, 1); + + // clean up + for (final file in files) { + await file.delete(); + } + }); + + test('sendException passes explicit stackTrace through', () async { + final trace = StackTrace.fromString( + '#0 myMethod (package:my_app/foo.dart:42:7)\n' + '#1 main (package:my_app/main.dart:10:3)', + ); + await Raygun.sendException(error: Exception('MESSAGE'), stackTrace: trace); + final frames = capturedBody['details']['error']['stackTrace'] as List; + expect(frames, isNotEmpty); + // first frame should be our explicit trace's first frame + expect(frames.first['fileName'], contains('foo.dart')); + expect(frames.first['lineNumber'], 42); + expect(frames.first['methodName'], contains('myMethod')); + }); + + test('stack-trace lines serialise with expected keys', () async { + await Raygun.sendException(error: Exception('MESSAGE')); + final frames = capturedBody['details']['error']['stackTrace'] as List; + expect(frames, isNotEmpty); + final first = frames.first as Map; + // matches Raygun API spec / .g.dart serialiser + expect( + first.keys, + containsAll([ + 'lineNumber', + 'className', + 'fileName', + 'methodName', + 'columnNumber', + ]), + ); + }); + + test('breadcrumb category, customData and className round-trip', () { + final original = RaygunBreadcrumbMessage( + message: 'msg', + category: 'navigation', + level: RaygunBreadcrumbLevel.warning, + customData: {'screen': 'home', 'count': 3}, + className: 'MyClass', + methodName: 'myMethod', + lineNumber: '17', + timestamp: 1700000000000, + ); + final json = original.toJson(); + expect(json['category'], 'navigation'); + expect(json['level'], 'warning'); + expect(json['customData'], {'screen': 'home', 'count': 3}); + expect(json['className'], 'MyClass'); + expect(json['methodName'], 'myMethod'); + expect(json['lineNumber'], '17'); + expect(json['timestamp'], 1700000000000); + + final decoded = RaygunBreadcrumbMessage.fromJson(json); + expect(decoded.category, 'navigation'); + expect(decoded.level, RaygunBreadcrumbLevel.warning); + expect(decoded.customData, {'screen': 'home', 'count': 3}); + expect(decoded.className, 'MyClass'); + expect(decoded.methodName, 'myMethod'); + expect(decoded.lineNumber, '17'); + }); + + test('RaygunUserInfo round-trips through JSON', () { + final user = RaygunUserInfo( + identifier: 'abc', + email: 'a@b.com', + firstName: 'A', + fullName: 'A B', + ); + final json = user.toJson(); + expect(json['identifier'], 'abc'); + expect(json['email'], 'a@b.com'); + expect(json['firstName'], 'A'); + expect(json['fullName'], 'A B'); + expect(json['isAnonymous'], false); + + final decoded = RaygunUserInfo.fromJson(json); + expect(decoded.identifier, 'abc'); + expect(decoded.email, 'a@b.com'); + expect(decoded.firstName, 'A'); + expect(decoded.fullName, 'A B'); + expect(decoded.isAnonymous, false); + }); + + test('nested innerError chain serialises to depth >1', () { + final outer = RaygunErrorMessage('Outer', 'outer message'); + final middle = RaygunErrorMessage('Middle', 'middle message'); + final inner = RaygunErrorMessage('Inner', 'inner message'); + middle.innerError = inner; + outer.innerError = middle; + + // Round-trip through JSON encoding so nested toJson() is invoked + // (the generated serialiser stores the inner object as-is). + final json = jsonDecode(jsonEncode(outer.toJson())) as Map; + expect(json['className'], 'Outer'); + expect(json['innerError']['className'], 'Middle'); + expect(json['innerError']['innerError']['className'], 'Inner'); + expect(json['innerError']['innerError']['message'], 'inner message'); + expect(json['innerError']['innerError']['innerError'], isNull); + }); + + test('onBeforeSend can mutate tags before send', () async { + Raygun.onBeforeSend = (payload) { + payload.details.tags.add('injected'); + return payload; + }; + await Raygun.sendException(error: Exception('MESSAGE'), tags: ['original']); + expect(capturedBody['details']['tags'], ['original', 'injected']); + Raygun.onBeforeSend = null; + }); + + test('onBeforeSend returning payload unchanged sends as-is', () async { + Raygun.onBeforeSend = (payload) => payload; + await Raygun.sendException(error: Exception('MESSAGE')); + expect(capturedBody['details']['error']['message'], 'Exception: MESSAGE'); + Raygun.onBeforeSend = null; + }); + + test('429 response retains the payload for retry', () async { + await _deleteOldFiles(); + Settings.customHttpClient = MockClient((request) async { + callCount += 1; + return http.Response('', 429); + }); + + await Raygun.sendException(error: Exception('MESSAGE')); + + // it was sent once + expect(callCount, 1); + // and the payload should have been stored for later retry + final files = await CrashReportingPostService.getCachedFiles(); + expect(files.length, 1); + + // clean up + for (final file in files) { + await file.delete(); + } + }); + + test('init called twice replaces the apiKey and version', () async { + await Raygun.init(apiKey: 'KEY1', version: '1.0.0'); + expect(Settings.apiKey, 'KEY1'); + await Raygun.init(apiKey: 'KEY2', version: '2.0.0'); + expect(Settings.apiKey, 'KEY2'); + expect(Settings.version, '2.0.0'); + }); } Future _deleteOldFiles() async {