diff --git a/README.md b/README.md index fa72290..814042f 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,140 @@ void main() { } ``` +### Mocking Edge Functions + +You can easily mock edge functions using the `registerEdgeFunction` method of `MockSupabaseHttpClient`. This method allows you to specify a handler function, giving you fine-grained control over the response based on the request body, HTTP method, and query parameters. You even have access to the mock database. + +```dart + group('Edge Functions Client', () { + test('invoke registered edge function with POST', () async { + mockHttpClient.registerEdgeFunction('greet', + (body, queryParams, method, tables) { + return FunctionResponse( + data: {'message': 'Hello, ${body['name']}!'}, + status: 200, + ); + }); + + final response = await mockSupabase.functions.invoke( + 'greet', + body: {'name': 'Alice'}, + ); + + expect(response.status, 200); + expect(response.data, {'message': 'Hello, Alice!'}); + }); + + test('invoke edge function with different HTTP methods', () async { + mockHttpClient.registerEdgeFunction('say-hello', + (body, queryParams, method, tables) { + final name = switch (method) { + HttpMethod.patch => 'Linda', + HttpMethod.post => 'Bob', + _ => 'Unknown' + }; + return FunctionResponse( + data: {'hello': name}, + status: 200, + ); + }); + + var patchResponse = await mockSupabase.functions + .invoke('say-hello', method: HttpMethod.patch); + expect(patchResponse.data, {'hello': 'Linda'}); + + var postResponse = await mockSupabase.functions + .invoke('say-hello', method: HttpMethod.post); + expect(postResponse.data, {'hello': 'Bob'}); + }); + + test('edge function receives query params and body', () async { + mockHttpClient.registerEdgeFunction('params-test', + (body, queryParams, method, tables) { + final city = queryParams['city']; + final street = body['street'] as String; + return FunctionResponse( + data: {'address': '$street, $city'}, + status: 200, + ); + }); + + final response = await mockSupabase.functions.invoke( + 'params-test', + body: {'street': '123 Main St'}, + queryParameters: {'city': 'Springfield'}, + ); + + expect(response.data, {'address': '123 Main St, Springfield'}); + }); + + test('edge function returns different content types', () async { + mockHttpClient.registerEdgeFunction('binary', + (body, queryParams, method, tables) { + return FunctionResponse( + data: Uint8List.fromList([1, 2, 3]), + status: 200, + ); + }); + + var response = await mockSupabase.functions.invoke('binary'); + expect(response.data is Uint8List, true); + expect((response.data as Uint8List).length, 3); + + mockHttpClient.registerEdgeFunction('text', + (body, queryParams, method, tables) { + return FunctionResponse( + data: 'Hello, world!', + status: 200, + ); + }); + + response = await mockSupabase.functions.invoke('text'); + expect(response.data, 'Hello, world!'); + + mockHttpClient.registerEdgeFunction('json', + (body, queryParams, method, tables) { + return FunctionResponse( + data: {'key': 'value'}, + status: 200, + ); + }); + + response = await mockSupabase.functions.invoke('json'); + expect(response.data, {'key': 'value'}); + }); + + test('edge function modifies mock database', () async { + mockHttpClient.registerEdgeFunction('add-user', + (body, queryParams, method, tables) { + final users = tables['public.users'] ?? []; + final newUser = { + 'id': users.length + 1, + 'name': body['name'], + }; + users.add(newUser); + tables['public.users'] = users; + return FunctionResponse(data: newUser, status: 201); + }); + + var users = await mockSupabase.from('users').select(); + expect(users, isEmpty); + + final response = await mockSupabase.functions.invoke( + 'add-user', + body: {'name': 'Alice'}, + ); + expect(response.status, 201); + expect(response.data, {'id': 1, 'name': 'Alice'}); + + users = await mockSupabase.from('users').select(); + expect(users, [ + {'id': 1, 'name': 'Alice'} + ]); + }); + }); +``` + ### Mocking Errors You can simulate error scenarios by configuring an error trigger callback. This is useful for testing how your application handles various error conditions: diff --git a/lib/src/mock_supabase_http_client.dart b/lib/src/mock_supabase_http_client.dart index d90c7d8..d46ed8e 100644 --- a/lib/src/mock_supabase_http_client.dart +++ b/lib/src/mock_supabase_http_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart'; import 'package:supabase/supabase.dart'; @@ -50,6 +51,20 @@ import 'utils/filter_parser.dart'; /// } /// ``` /// +/// You can mock edge functions using the [registerEdgeFunction] callback: +/// ```dart +/// final client = MockSupabaseHttpClient(); +/// client.registerEdgeFunction( +/// 'get_user_status', +/// (body, queryParameters, method, tables) { +/// return FunctionResponse( +/// data: {'status': 'active'}, +/// status: 200, +/// ); +/// +/// final response = await supabaseClient.functions.invoke('get_user_status'); +/// ``` +/// /// You can simulate errors using the [postgrestExceptionTrigger] callback: /// ```dart /// final client = MockSupabaseHttpClient( @@ -76,6 +91,14 @@ import 'utils/filter_parser.dart'; /// {@endtemplate} class MockSupabaseHttpClient extends BaseClient { final Map>> _database = {}; + final Map< + String, + FunctionResponse Function( + Map body, + Map queryParameters, + HttpMethod method, + Map>> tables, + )> _edgeFunctions = {}; final Map< String, dynamic Function(Map? params, @@ -122,6 +145,7 @@ class MockSupabaseHttpClient extends BaseClient { // Clear the mock database and RPC functions _database.clear(); _rpcHandler.reset(); + _edgeFunctions.clear(); } /// Registers a RPC function that can be called using the `rpc` method on a `Postgrest` client. @@ -206,6 +230,19 @@ class MockSupabaseHttpClient extends BaseClient { @override Future send(BaseRequest request) async { + final functionName = _extractFunctionName(request.url); + if (functionName != null) { + if (_edgeFunctions.containsKey(functionName)) { + return _handleFunctionInvocation(functionName, request); + } else { + return _createResponse( + {'error': 'Function $functionName not found'}, + statusCode: 404, + request: request, + ); + } + } + // Decode the request body if it's not a GET, DELETE, or HEAD request dynamic body; if (request.method != 'GET' && @@ -780,13 +817,125 @@ class MockSupabaseHttpClient extends BaseClient { 'content-type': 'application/json; charset=utf-8', ...?headers, }; + Stream> stream; + if (data is Uint8List) { + stream = Stream.value(data); + responseHeaders['content-type'] = _getContentType(data); + } else if (data is String) { + stream = Stream.value(utf8.encode(data)); + responseHeaders['content-type'] = _getContentType(data); + } else { + final jsonData = jsonEncode(data); + stream = Stream.value(utf8.encode(jsonData)); + } + return StreamedResponse( - Stream.value(utf8.encode(data is String ? data : jsonEncode(data))), + stream, statusCode, headers: responseHeaders, request: request, ); } + + /// Registers an edge function with the given name and handler. + /// + /// The [name] parameter specifies the name of the edge function. + /// + /// The [handler] parameter is a function that takes the following parameters: + /// - [body]: A map containing the body of the request. + /// - [queryParameters]: A map containing the query parameters of the request. + /// - [method]: The HTTP method of the request. + /// - [tables]: A map containing lists of maps representing the tables involved in the request. + /// + /// The [handler] function should return a [FunctionResponse]. + void registerEdgeFunction( + String name, + FunctionResponse Function( + Map body, + Map queryParameters, + HttpMethod method, + Map>> tables, + ) handler, + ) { + _edgeFunctions[name] = handler; + } + + String? _extractFunctionName(Uri url) { + final pathSegments = url.pathSegments; + // Handle functions endpoint: /functions/v1/{function_name} + if (pathSegments.length >= 3 && + pathSegments[0] == 'functions' && + pathSegments[1] == 'v1') { + return pathSegments[2]; + } + return null; + } + + Future _handleFunctionInvocation( + String functionName, + BaseRequest request, + ) async { + if (!_edgeFunctions.containsKey(functionName)) { + return _createResponse( + {'error': 'Edge function $functionName not found'}, + statusCode: 404, + request: request, + ); + } + + // Parse request data + final tables = _database; + final body = await _parseRequestBody(request); + final queryParams = request.url.queryParameters; + final method = _parseMethod(request.method); + + // Call handler + final response = _edgeFunctions[functionName]!( + body ?? {}, + queryParams, + method, + tables, + ); + + return _createResponse( + response.data, + statusCode: response.status, + request: request, + headers: { + 'content-type': _getContentType(response.data), + }, + ); + } + + Future _parseRequestBody(BaseRequest request) async { + if (request is! Request) return null; + final content = await request.finalize().transform(utf8.decoder).join(); + return content.isEmpty ? null : jsonDecode(content); + } + + HttpMethod _parseMethod(String method) { + switch (method.toUpperCase()) { + case 'GET': + return HttpMethod.get; + case 'POST': + return HttpMethod.post; + case 'PATCH': + return HttpMethod.patch; + case 'DELETE': + return HttpMethod.delete; + case 'PUT': + return HttpMethod.put; + default: + return HttpMethod.get; + } + } + + String _getContentType(dynamic data) { + if (data is Uint8List) return 'application/octet-stream'; + if (data is String) return 'text/plain'; + if (data is Stream>) return 'text/event-stream'; + return 'application/json'; + } } /// Represents the different types of HTTP requests that can be made to the Supabase API diff --git a/test/mock_supabase_http_client_test.dart b/test/mock_supabase_http_client_test.dart index e492045..bb03b10 100644 --- a/test/mock_supabase_http_client_test.dart +++ b/test/mock_supabase_http_client_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:mock_supabase_http_client/mock_supabase_http_client.dart'; import 'package:supabase/supabase.dart'; import 'package:test/test.dart'; @@ -1157,6 +1159,145 @@ void main() { }); }); + group('Edge Functions Client', () { + test('invoke registered edge function with POST', () async { + mockHttpClient.registerEdgeFunction('greet', + (body, queryParams, method, tables) { + return FunctionResponse( + data: {'message': 'Hello, ${body['name']}!'}, + status: 200, + ); + }); + + final response = await mockSupabase.functions.invoke( + 'greet', + body: {'name': 'Alice'}, + ); + + expect(response.status, 200); + expect(response.data, {'message': 'Hello, Alice!'}); + }); + + test('invoke edge function with different HTTP methods', () async { + mockHttpClient.registerEdgeFunction('say-hello', + (body, queryParams, method, tables) { + final name = switch (method) { + HttpMethod.patch => 'Linda', + HttpMethod.post => 'Bob', + _ => 'Unknown' + }; + return FunctionResponse( + data: {'hello': name}, + status: 200, + ); + }); + + var patchResponse = await mockSupabase.functions + .invoke('say-hello', method: HttpMethod.patch); + expect(patchResponse.data, {'hello': 'Linda'}); + + var postResponse = await mockSupabase.functions + .invoke('say-hello', method: HttpMethod.post); + expect(postResponse.data, {'hello': 'Bob'}); + }); + + test('edge function receives query params and body', () async { + mockHttpClient.registerEdgeFunction('params-test', + (body, queryParams, method, tables) { + final city = queryParams['city']; + final street = body['street'] as String; + return FunctionResponse( + data: {'address': '$street, $city'}, + status: 200, + ); + }); + + final response = await mockSupabase.functions.invoke( + 'params-test', + body: {'street': '123 Main St'}, + queryParameters: {'city': 'Springfield'}, + ); + + expect(response.data, {'address': '123 Main St, Springfield'}); + }); + + test('edge function returns different content types', () async { + mockHttpClient.registerEdgeFunction('binary', + (body, queryParams, method, tables) { + return FunctionResponse( + data: Uint8List.fromList([1, 2, 3]), + status: 200, + ); + }); + + var response = await mockSupabase.functions.invoke('binary'); + expect(response.data is Uint8List, true); + expect((response.data as Uint8List).length, 3); + + mockHttpClient.registerEdgeFunction('text', + (body, queryParams, method, tables) { + return FunctionResponse( + data: 'Hello, world!', + status: 200, + ); + }); + + response = await mockSupabase.functions.invoke('text'); + expect(response.data, 'Hello, world!'); + + mockHttpClient.registerEdgeFunction('json', + (body, queryParams, method, tables) { + return FunctionResponse( + data: {'key': 'value'}, + status: 200, + ); + }); + + response = await mockSupabase.functions.invoke('json'); + expect(response.data, {'key': 'value'}); + }); + + test('invoke non-existent edge function returns 404', () async { + expect( + () async => await mockSupabase.functions.invoke('not-found'), + throwsA(isA().having( + (e) => e.status, + 'statusCode', + 404, + )), + ); + }); + + test('edge function modifies mock database', () async { + mockHttpClient.registerEdgeFunction('add-user', + (body, queryParams, method, tables) { + final users = tables['public.users'] ?? []; + final newUser = { + 'id': users.length + 1, + 'name': body['name'], + }; + users.add(newUser); + tables['public.users'] = users; + return FunctionResponse(data: newUser, status: 201); + }); + + var users = await mockSupabase.from('users').select(); + expect(users, isEmpty); + + final response = await mockSupabase.functions.invoke( + 'add-user', + body: {'name': 'Alice'}, + ); + expect(response.status, 201); + expect(response.data, {'id': 1, 'name': 'Alice'}); + + users = await mockSupabase.from('users').select(); + expect(users, [ + {'id': 1, 'name': 'Alice'} + ]); + }); + }); + group('mock exceptions', () { group('basic operation exceptions', () { test('select throws PostgrestException', () async {