diff --git a/packages/amplify_datastore/lib/amplify_datastore.dart b/packages/amplify_datastore/lib/amplify_datastore.dart index 95ebed3bc0..aece0b2bbb 100644 --- a/packages/amplify_datastore/lib/amplify_datastore.dart +++ b/packages/amplify_datastore/lib/amplify_datastore.dart @@ -106,7 +106,7 @@ class AmplifyDataStore extends DataStorePluginInterface if (apiPlugin != null && gqlConfig != null) { // ignore: invalid_use_of_protected_member final authProviders = apiPlugin.authProviders; - final nativePlugin = _NativeAmplifyApi(authProviders); + final nativePlugin = NativeAmplifyApi(authProviders); NativeApiPlugin.setup(nativePlugin); final nativeBridge = NativeApiBridge(); @@ -288,10 +288,11 @@ class _NativeAmplifyAuthCognito String get runtimeTypeName => '_NativeAmplifyAuthCognito'; } -class _NativeAmplifyApi +@visibleForTesting +class NativeAmplifyApi with AWSDebuggable, AmplifyLoggerMixin implements NativeApiPlugin { - _NativeAmplifyApi(this._authProviders); + NativeAmplifyApi(this._authProviders); /// The registered [APIAuthProvider] instances. final Map, APIAuthProvider> diff --git a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart index 9e1493176a..32eceac5eb 100644 --- a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart +++ b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_datastore/src/native_plugin.g.dart'; -import 'package:collection/collection.dart'; /// Convert a [NativeGraphQLResponse] to a [GraphQLResponse] GraphQLRequest nativeRequestToGraphQLRequest( @@ -39,7 +38,7 @@ APIAuthorizationType? nativeToApiAuthorizationType(String? authMode) { NativeGraphQLResponse graphQLResponseToNativeResponse( GraphQLResponse response) { final errorJson = jsonEncode( - response.errors.whereNotNull().map((e) => e.toJson()).toList()); + response.errors.map((e) => e.toJson()).toList()); return NativeGraphQLResponse( payloadJson: response.data, errorsJson: errorJson, diff --git a/packages/amplify_datastore/test/native_amplify_api_test.dart b/packages/amplify_datastore/test/native_amplify_api_test.dart new file mode 100644 index 0000000000..546e602f18 --- /dev/null +++ b/packages/amplify_datastore/test/native_amplify_api_test.dart @@ -0,0 +1,791 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_datastore/amplify_datastore.dart'; +import 'package:amplify_datastore/src/native_plugin.g.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + TestWidgetsFlutterBinding binding = + TestWidgetsFlutterBinding.ensureInitialized(); + + BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.amplify_datastore.NativeApiBridge.sendSubscriptionEvent', + MockNativeAuthBridgeCodec(), + ); + + MockAPIPlugin mockAPIPlugin = MockAPIPlugin(); + + setUp(() async { + mockAPIPlugin.clear(); + + await Amplify.API.addPlugin( + mockAPIPlugin, + authProviderRepo: AmplifyAuthProviderRepository(), + ); + + binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + channel, + null, + ); + }); + + tearDown(() async { + await Amplify.reset(); + }); + + group('NativeAmplifyAPI', () { + group('Query', () { + test('Empty Request/Response', () async { + String document = ''; + Map emptyMap = {}; + String emptyJsonArray = '[]'; + + mockAPIPlugin.queryMethod = (GraphQLRequest mockRequest) { + expect(mockRequest.document, document); + expect(mockRequest.variables, emptyMap); + expect(mockRequest.apiName, null); + expect(mockRequest.authorizationMode, null); + expect(mockRequest.headers, null); + expect(mockRequest.decodePath, null); + expect(mockRequest.modelType, null); + + return GraphQLOperation( + CancelableOperation>.fromValue( + GraphQLResponse( + data: null as String?, + errors: [], + ), + ), + ); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest(document: ''); + NativeGraphQLResponse response = await nativeAmplifyApi.query(request); + + expect(response.payloadJson, null); + expect(response.errorsJson, emptyJsonArray); + }); + + test('Filled Request/Response', () async { + String document = 'document'; + String apiName = 'apiName'; + String variable1Key = 'variable1'; + int variable1Value = 1; + String variable2Key = 'variable2'; + String variable2Value = 'test string'; + String variablesJson = + '{"$variable1Key": $variable1Value, "$variable2Key": "$variable2Value"}'; + String responseType = 'responseType'; + String decodePath = 'decodePath'; + String options = 'options'; + String payloadJson = '{}'; + String errorMessage1 = 'errorsJson1'; + String errorMessage2 = 'errorsJson2'; + String errorsJson = + '[{"message":"$errorMessage1"},{"message":"$errorMessage2"}]'; + String authMode = 'apiKey'; + + mockAPIPlugin.queryMethod = (GraphQLRequest mockRequest) { + expect(mockRequest.id.length, greaterThan(0)); + expect(mockRequest.document, document); + expect(mockRequest.variables[variable1Key], variable1Value); + expect(mockRequest.variables[variable2Key], variable2Value); + expect(mockRequest.apiName, apiName); + expect(mockRequest.authorizationMode, APIAuthorizationType.apiKey); + expect(mockRequest.headers, null); + expect(mockRequest.decodePath, null); + expect(mockRequest.modelType, null); + + return GraphQLOperation( + CancelableOperation>.fromValue( + GraphQLResponse( + data: payloadJson as String?, + errors: [ + GraphQLResponseError(message: errorMessage1), + GraphQLResponseError(message: errorMessage2), + ], + ), + ), + ); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: document, + apiName: apiName, + variablesJson: variablesJson, + responseType: responseType, + decodePath: decodePath, + options: options, + authMode: authMode); + NativeGraphQLResponse response = await nativeAmplifyApi.query(request); + + expect(response.payloadJson, payloadJson); + expect(response.errorsJson, errorsJson); + }); + + Future _authModeExpectHelepr( + String? authMode, + APIAuthorizationType? expected, + ) async { + mockAPIPlugin.queryMethod = (GraphQLRequest mockRequest) { + expect(mockRequest.authorizationMode, expected); + + return GraphQLOperation( + CancelableOperation>.fromValue( + GraphQLResponse( + data: null as String?, + errors: [], + ), + ), + ); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + await nativeAmplifyApi.query( + NativeGraphQLRequest( + document: '', + authMode: authMode, + ), + ); + } + + test('AuthModeTypes', () async { + await _authModeExpectHelepr('apiKey', APIAuthorizationType.apiKey); + await _authModeExpectHelepr('awsIAM', APIAuthorizationType.iam); + await _authModeExpectHelepr('openIDConnect', APIAuthorizationType.oidc); + await _authModeExpectHelepr('amazonCognitoUserPools', APIAuthorizationType.userPools); + await _authModeExpectHelepr('function', APIAuthorizationType.function); + await _authModeExpectHelepr('none', APIAuthorizationType.none); + await _authModeExpectHelepr(null, null); + }); + + test('Invalid Request Variables Json Exception', () async { + String document = ''; + String invalidJson = 'INVALID JSON'; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: document, + variablesJson: invalidJson, + ); + + expect(() async => await nativeAmplifyApi.query(request), + throwsA(TypeMatcher())); + }); + + test('API Exception', () async { + String exceptionMessage = 'API Exception'; + + mockAPIPlugin.queryMethod = (GraphQLRequest mockRequest) { + throw NetworkException(exceptionMessage); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: '', + ); + + expect( + () async => await nativeAmplifyApi.query(request), + throwsA( + predicate( + (e) => e is NetworkException && e.message == exceptionMessage), + ), + ); + }); + }); + + group('Mutate', () { + test('Empty Request/Response', () async { + String document = ''; + Map emptyMap = {}; + String emptyJsonArray = '[]'; + + mockAPIPlugin.mutateMethod = (GraphQLRequest mockRequest) { + expect(mockRequest.document, document); + expect(mockRequest.variables, emptyMap); + expect(mockRequest.apiName, null); + expect(mockRequest.authorizationMode, null); + expect(mockRequest.headers, null); + expect(mockRequest.decodePath, null); + expect(mockRequest.modelType, null); + + return GraphQLOperation( + CancelableOperation>.fromValue( + GraphQLResponse( + data: null as String?, + errors: [], + ), + ), + ); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest(document: document); + NativeGraphQLResponse response = await nativeAmplifyApi.mutate(request); + + expect(response.payloadJson, null); + expect(response.errorsJson, emptyJsonArray); + }); + + test('Filled Request/Response', () async { + String document = 'document'; + String apiName = 'apiName'; + String authMode = 'apiKey'; + String variable1Key = 'variable1'; + int variable1Value = 1; + String variable2Key = 'variable2'; + String variable2Value = 'test string'; + String variablesJson = + '{"$variable1Key": $variable1Value, "$variable2Key": "$variable2Value"}'; + String responseType = 'responseType'; + String decodePath = 'decodePath'; + String options = 'options'; + String payloadJson = '{}'; + String errorMessage1 = 'errorsJson1'; + String errorMessage2 = 'errorsJson2'; + String errorsJson = + '[{"message":"$errorMessage1"},{"message":"$errorMessage2"}]'; + + mockAPIPlugin.mutateMethod = (GraphQLRequest mockRequest) { + expect(mockRequest.id.length, greaterThan(0)); + expect(mockRequest.document, document); + expect(mockRequest.variables[variable1Key], variable1Value); + expect(mockRequest.variables[variable2Key], variable2Value); + expect(mockRequest.apiName, apiName); + expect(mockRequest.authorizationMode, APIAuthorizationType.apiKey); + expect(mockRequest.headers, null); + expect(mockRequest.decodePath, null); + expect(mockRequest.modelType, null); + + return GraphQLOperation( + CancelableOperation>.fromValue( + GraphQLResponse( + data: payloadJson as String?, + errors: [ + GraphQLResponseError(message: errorMessage1), + GraphQLResponseError(message: errorMessage2), + ], + ), + ), + ); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: document, + apiName: apiName, + authMode: authMode, + variablesJson: variablesJson, + responseType: responseType, + decodePath: decodePath, + options: options, + ); + NativeGraphQLResponse response = await nativeAmplifyApi.mutate(request); + + expect(response.payloadJson, payloadJson); + expect(response.errorsJson, errorsJson); + }); + + test('Invalid Request Variables Json Exception', () async { + String document = ''; + String invalidJson = 'INVALID JSON'; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: document, + variablesJson: invalidJson, + ); + + expect(() async => await nativeAmplifyApi.mutate(request), + throwsA(TypeMatcher())); + }); + + test('API Exception', () async { + String exceptionMessage = 'API Exception'; + + mockAPIPlugin.mutateMethod = (GraphQLRequest mockRequest) { + throw NetworkException(exceptionMessage); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: '', + ); + + expect( + () async => await nativeAmplifyApi.mutate(request), + throwsA( + predicate( + (e) => e is NetworkException && e.message == 'API Exception'), + ), + ); + }); + }); + + group('Subscribe', () { + test('Empty Request/Response', () async { + String responseType = 'connecting'; + String document = ''; + Map emptyMap = {}; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + expect(mockRequest.document, document); + expect(mockRequest.variables, emptyMap); + expect(mockRequest.apiName, null); + expect(mockRequest.authorizationMode, null); + expect(mockRequest.headers, null); + expect(mockRequest.decodePath, null); + expect(mockRequest.modelType, null); + + var controller = StreamController>(); + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest(document: document); + NativeGraphQLSubscriptionResponse response = + await nativeAmplifyApi.subscribe(request); + + expect(response.payloadJson, null); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, responseType); + }); + + test('Filled Request/Response', () async { + String responseType = 'connecting'; + String document = 'document'; + String apiName = 'apiName'; + String authMode = 'apiKey'; + String variable1Key = 'variable1'; + int variable1Value = 1; + String variable2Key = 'variable2'; + String variable2Value = 'test string'; + String variablesJson = + '{"$variable1Key": $variable1Value, "$variable2Key": "$variable2Value"}'; + String decodePath = 'decodePath'; + String options = 'options'; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + expect(mockRequest.id.length, greaterThan(0)); + expect(mockRequest.document, document); + expect(mockRequest.variables[variable1Key], variable1Value); + expect(mockRequest.variables[variable2Key], variable2Value); + expect(mockRequest.apiName, apiName); + expect(mockRequest.authorizationMode, APIAuthorizationType.apiKey); + expect(mockRequest.headers, null); + expect(mockRequest.decodePath, null); + expect(mockRequest.modelType, null); + + var controller = StreamController>(); + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: document, + apiName: apiName, + authMode: authMode, + variablesJson: variablesJson, + responseType: responseType, + decodePath: decodePath, + options: options, + ); + NativeGraphQLSubscriptionResponse response = + await nativeAmplifyApi.subscribe(request); + + expect(response.payloadJson, null); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, responseType); + }); + + test('Invalid Request Variables Json Exception', () async { + String document = ''; + String invalidJson = 'INVALID JSON'; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: document, + variablesJson: invalidJson, + ); + + expect(() async => await nativeAmplifyApi.subscribe(request), + throwsA(TypeMatcher())); + }); + + test('API Exception', () async { + String exceptionMessage = 'API Exception'; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + throw NetworkException(exceptionMessage); + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: '', + ); + + expect( + () async => await nativeAmplifyApi.subscribe(request), + throwsA( + predicate( + (e) => e is NetworkException && e.message == 'API Exception'), + ), + ); + }); + + test('Established/Connected Callback', () async { + void Function()? onEstablishedCallback; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + onEstablishedCallback = onEstablished; + var controller = StreamController>(); + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + NativeGraphQLRequest request = NativeGraphQLRequest( + document: '', + ); + + await nativeAmplifyApi.subscribe(request); + + binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + channel, + (Object? message) { + expect(message, isA()); + List responses = message as List; + + expect(responses.length, greaterThan(0)); + expect(responses[0], isA()); + NativeGraphQLSubscriptionResponse response = responses[0]; + + expect(response.payloadJson, null); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, 'start_ack'); + + return Future.value(message); + }, + ); + + expect(onEstablishedCallback, isNotNull); + expect(() => onEstablishedCallback?.call(), returnsNormally); + }); + + test('Send Data Event', () async { + String payloadJson = 'payloadJson'; + StreamController? responseController; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + var controller = StreamController>(); + responseController = controller; + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + await nativeAmplifyApi.subscribe( + NativeGraphQLRequest(document: ''), + ); + + binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + channel, + (Object? message) { + expect(message, isA()); + List responses = message as List; + + expect(responses.length, greaterThan(0)); + expect(responses[0], isA()); + NativeGraphQLSubscriptionResponse response = responses[0]; + + expect(response.payloadJson, payloadJson); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, 'data'); + + return Future.value(message); + }, + ); + + expect(responseController, isNotNull); + expect( + () => responseController?.add( + GraphQLResponse( + data: payloadJson, + errors: [], + ), + ), + returnsNormally, + ); + }); + + test('Send Data With Errors Event', () async { + String payloadJson = 'payloadJson'; + String errorMessage1 = 'errorsJson1'; + String errorMessage2 = 'errorsJson2'; + String errorsJson = + '{"errors":[{"message":"$errorMessage1"},{"message":"$errorMessage2"}]}'; + + StreamController? responseController; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + var controller = StreamController>(); + responseController = controller; + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + await nativeAmplifyApi.subscribe( + NativeGraphQLRequest(document: ''), + ); + + binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + channel, + (Object? message) { + expect(message, isA()); + List responses = message as List; + + expect(responses.length, greaterThan(0)); + expect(responses[0], isA()); + NativeGraphQLSubscriptionResponse response = responses[0]; + + expect(response.payloadJson, errorsJson); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, 'error'); + + return Future.value(message); + }, + ); + + expect(responseController, isNotNull); + expect( + () => responseController?.add( + GraphQLResponse( + data: payloadJson, + errors: [ + GraphQLResponseError(message: errorMessage1), + GraphQLResponseError(message: errorMessage2), + ], + ), + ), + returnsNormally, + ); + }); + + test('Send Error Event', () async { + String exceptionMessage = 'Intentional Error'; + String errorsJson = + '{"errors":[{"message":"Exception: $exceptionMessage"}]}'; + + StreamController? responseController; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + var controller = StreamController>(); + responseController = controller; + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + await nativeAmplifyApi.subscribe( + NativeGraphQLRequest(document: ''), + ); + + binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + channel, + (Object? message) { + expect(message, isA()); + List responses = message as List; + + expect(responses.length, greaterThan(0)); + expect(responses[0], isA()); + NativeGraphQLSubscriptionResponse response = responses[0]; + + expect(response.payloadJson, errorsJson); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, 'error'); + + return Future.value(message); + }, + ); + + expect(responseController, isNotNull); + expect( + () => responseController?.addError(Exception(exceptionMessage)), + returnsNormally, + ); + }); + + test('Send Done Event', () async { + StreamController? responseController; + + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + var controller = StreamController>(); + responseController = controller; + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + await nativeAmplifyApi.subscribe( + NativeGraphQLRequest(document: ''), + ); + + binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + channel, + (Object? message) { + expect(message, isA()); + List responses = message as List; + + expect(responses.length, greaterThan(0)); + expect(responses[0], isA()); + NativeGraphQLSubscriptionResponse response = responses[0]; + + expect(response.payloadJson, null); + expect(response.subscriptionId.length, greaterThan(0)); + expect(response.type, 'complete'); + + return Future.value(message); + }, + ); + + expect(responseController, isNotNull); + expect( + () => responseController?.close(), + returnsNormally, + ); + }); + + group('Unubscribe', () { + test('Existing Subscription', () async { + mockAPIPlugin.subscribeMethod = ( + GraphQLRequest mockRequest, + void Function()? onEstablished, + ) { + var controller = StreamController>(); + return controller.stream; + }; + + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + var response = await nativeAmplifyApi.subscribe( + NativeGraphQLRequest( + document: '', + ), + ); + + await nativeAmplifyApi.unsubscribe(response.subscriptionId); + }); + + test('Fake Subscription', () async { + NativeAmplifyApi nativeAmplifyApi = NativeAmplifyApi({}); + + await nativeAmplifyApi.unsubscribe('Fake Subscription ID'); + }); + }); + }); + }); +} + +class MockAPIPlugin extends APIPluginInterface { + GraphQLOperation Function(GraphQLRequest)? queryMethod; + GraphQLOperation Function(GraphQLRequest)? mutateMethod; + Stream> Function(GraphQLRequest, void Function()?)? + subscribeMethod; + + MockAPIPlugin({ + this.queryMethod, + this.mutateMethod, + this.subscribeMethod, + }); + + void clear() { + queryMethod = null; + mutateMethod = null; + subscribeMethod = null; + } + + @override + GraphQLOperation query({required GraphQLRequest request}) { + return queryMethod!(request); + } + + @override + GraphQLOperation mutate({required GraphQLRequest request}) { + return mutateMethod!(request); + } + + @override + Stream> subscribe( + GraphQLRequest request, { + void Function()? onEstablished, + }) { + return subscribeMethod!(request, onEstablished); + } +} + +class MockNativeAuthBridgeCodec extends StandardMessageCodec { + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is NativeGraphQLSubscriptionResponse) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return NativeGraphQLSubscriptionResponse.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +}