diff --git a/packages/functions_client/lib/src/functions_client.dart b/packages/functions_client/lib/src/functions_client.dart index 7506e223..84b44674 100644 --- a/packages/functions_client/lib/src/functions_client.dart +++ b/packages/functions_client/lib/src/functions_client.dart @@ -119,15 +119,16 @@ class FunctionsClient { } else { final bodyRequest = http.Request(method.name, uri); - final String? bodyStr; if (body == null) { - bodyStr = null; + // No body to set } else if (body is String) { - bodyStr = body; + bodyRequest.body = body; + } else if (body is Uint8List) { + bodyRequest.bodyBytes = body; } else { - bodyStr = await _isolate.encode(body); + final bodyStr = await _isolate.encode(body); + bodyRequest.body = bodyStr; } - if (bodyStr != null) bodyRequest.body = bodyStr; request = bodyRequest; } diff --git a/packages/functions_client/test/custom_http_client.dart b/packages/functions_client/test/custom_http_client.dart index 45ec449f..62dae075 100644 --- a/packages/functions_client/test/custom_http_client.dart +++ b/packages/functions_client/test/custom_http_client.dart @@ -24,6 +24,7 @@ class CustomHttpClient extends BaseClient { headers: { "Content-Type": "application/json", }, + reasonPhrase: "Enhance Your Calm", ); } else if (request.url.path.endsWith('sse')) { return StreamedResponse( @@ -32,8 +33,37 @@ class CustomHttpClient extends BaseClient { headers: { "Content-Type": "text/event-stream", }); + } else if (request.url.path.endsWith('binary')) { + return StreamedResponse( + Stream.value([1, 2, 3, 4, 5]), + 200, + request: request, + headers: { + "Content-Type": "application/octet-stream", + }, + ); + } else if (request.url.path.endsWith('text')) { + return StreamedResponse( + Stream.value(utf8.encode('Hello World')), + 200, + request: request, + headers: { + "Content-Type": "text/plain", + }, + ); + } else if (request.url.path.endsWith('empty-json')) { + return StreamedResponse( + Stream.value([]), + 200, + request: request, + headers: { + "Content-Type": "application/json", + }, + ); } else { final Stream> stream; + final Map headers; + if (request is MultipartRequest) { stream = Stream.value( utf8.encode(jsonEncode([ @@ -44,16 +74,27 @@ class CustomHttpClient extends BaseClient { } ])), ); + headers = {"Content-Type": "application/json"}; } else { - stream = Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))); + // Check if the request contains binary data (Uint8List) + final isOctetStream = + request.headers['Content-Type'] == 'application/octet-stream'; + if (isOctetStream) { + // Return the original binary data + final bodyBytes = (request as Request).bodyBytes; + stream = Stream.value(bodyBytes); + headers = {"Content-Type": "application/octet-stream"}; + } else { + stream = + Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))); + headers = {"Content-Type": "application/json"}; + } } return StreamedResponse( stream, 200, request: request, - headers: { - "Content-Type": "application/json", - }, + headers: headers, ); } } diff --git a/packages/functions_client/test/functions_dart_test.dart b/packages/functions_client/test/functions_dart_test.dart index 4ef7b5c5..48074f2c 100644 --- a/packages/functions_client/test/functions_dart_test.dart +++ b/packages/functions_client/test/functions_dart_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:functions_client/src/functions_client.dart'; import 'package:functions_client/src/types.dart'; @@ -152,6 +153,214 @@ void main() { expect(req.body, '{"thekey":"thevalue"}'); expect(req.headers["Content-Type"], contains("application/json")); }); + + test('Uint8List is properly encoded as binary data', () async { + final binaryData = Uint8List.fromList([1, 2, 3, 4, 5]); + await functionsCustomHttpClient.invoke('function', body: binaryData); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.bodyBytes, equals(binaryData)); + expect(req.headers["Content-Type"], equals("application/octet-stream")); + }); + + test('null body sends no content-type', () async { + await functionsCustomHttpClient.invoke('function'); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.body, ''); + expect(req.headers.containsKey("Content-Type"), isFalse); + }); + }); + + group('HTTP methods', () { + test('GET method', () async { + await functionsCustomHttpClient.invoke( + 'function', + method: HttpMethod.get, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.method, 'get'); + }); + + test('PUT method', () async { + await functionsCustomHttpClient.invoke( + 'function', + method: HttpMethod.put, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.method, 'put'); + }); + + test('DELETE method', () async { + await functionsCustomHttpClient.invoke( + 'function', + method: HttpMethod.delete, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.method, 'delete'); + }); + + test('PATCH method', () async { + await functionsCustomHttpClient.invoke( + 'function', + method: HttpMethod.patch, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.method, 'patch'); + }); + }); + + group('Headers', () { + test('setAuth updates authorization header', () async { + functionsCustomHttpClient.setAuth('new-token'); + + await functionsCustomHttpClient.invoke('function'); + + final req = customHttpClient.receivedRequests.last; + expect(req.headers['Authorization'], 'Bearer new-token'); + }); + + test('headers getter returns current headers', () { + functionsCustomHttpClient.setAuth('test-token'); + + final headers = functionsCustomHttpClient.headers; + expect(headers['Authorization'], 'Bearer test-token'); + expect(headers, contains('X-Client-Info')); + }); + + test('custom headers override defaults', () async { + await functionsCustomHttpClient.invoke( + 'function', + headers: {'Content-Type': 'custom/type'}, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.headers['Content-Type'], 'custom/type'); + }); + + test('custom headers merge with defaults', () async { + await functionsCustomHttpClient.invoke( + 'function', + headers: {'X-Custom': 'value'}, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.headers['X-Custom'], 'value'); + expect(req.headers, contains('X-Client-Info')); + }); + }); + + group('Constructor variations', () { + test('constructor with all parameters', () { + final isolate = YAJsonIsolate(); + final httpClient = CustomHttpClient(); + final client = FunctionsClient( + 'https://example.com', + {'X-Test': 'value'}, + httpClient: httpClient, + isolate: isolate, + ); + + expect(client.headers['X-Test'], 'value'); + expect(client.headers, contains('X-Client-Info')); + }); + + test('constructor with minimal parameters', () { + final client = FunctionsClient('https://example.com', {}); + + expect(client.headers, contains('X-Client-Info')); + }); + }); + + group('Multipart requests', () { + test('multipart with both files and fields', () async { + await functionsCustomHttpClient.invoke( + 'function', + body: {'field1': 'value1', 'field2': 'value2'}, + files: [ + MultipartFile.fromString('file1', 'content1'), + MultipartFile.fromString('file2', 'content2'), + ], + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.headers['Content-Type'], contains('multipart/form-data')); + expect(req, isA()); + }); + + test('multipart with only files', () async { + await functionsCustomHttpClient.invoke( + 'function', + files: [MultipartFile.fromString('file', 'content')], + ); + + final req = customHttpClient.receivedRequests.last; + expect(req.headers['Content-Type'], contains('multipart/form-data')); + expect(req, isA()); + }); + }); + + group('Response content types', () { + test('handles application/octet-stream response', () async { + final res = await functionsCustomHttpClient.invoke('binary'); + + expect(res.data, isA()); + expect(res.data, equals(Uint8List.fromList([1, 2, 3, 4, 5]))); + expect(res.status, 200); + }); + + test('handles text/plain response', () async { + final res = await functionsCustomHttpClient.invoke('text'); + + expect(res.data, isA()); + expect(res.data, 'Hello World'); + expect(res.status, 200); + }); + + test('handles empty JSON response', () async { + final res = await functionsCustomHttpClient.invoke('empty-json'); + + expect(res.data, ''); + expect(res.status, 200); + }); + }); + + group('Error handling', () { + test('FunctionException contains all error details', () async { + try { + await functionsCustomHttpClient.invoke('error-function'); + fail('should throw'); + } on FunctionException catch (e) { + expect(e.status, 420); + expect(e.details, isNotNull); + expect(e.reasonPhrase, isNotNull); + expect(e.toString(), contains('420')); + } + }); + }); + + group('Edge cases', () { + test('multipart request with invalid body type throws assertion', + () async { + expect( + () => functionsCustomHttpClient.invoke( + 'function', + body: 42, // Invalid: should be Map for multipart + files: [MultipartFile.fromString('file', 'content')], + ), + throwsA(isA()), + ); + }); }); }); }