From ac0e788d879ef7e62f7694562aff990daecaeb9f Mon Sep 17 00:00:00 2001 From: Sai Gokula Krishnan Date: Mon, 7 Oct 2024 13:32:53 +0530 Subject: [PATCH 1/9] 2.2.2-dev.2 --- CHANGELOG.md | 7 +++ README.md | 2 +- lib/client.dart | 119 +++++++++++++++++++++++++++++++----------------- pubspec.yaml | 2 +- 4 files changed, 86 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85666a7..4237e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog 📝 + +### v2.2.2-dev.2 🛠️ +- Potential fix for multiple connection issue when using single instance method + - Solves [#32](https://github.com/Imgkl/EventFlux/issues/32) +- Added a check to ensure that stream is not closed before sending data + + ### v2.2.1 🚀 - Added Multipart/files support - Now you can send multipart/files data to the server. diff --git a/README.md b/README.md index 411375d..7e965e8 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Add EventFlux to your Dart project's dependencies, and you're golden: ```yaml dependencies: - eventflux: ^2.2.1 + eventflux: ^2.2.2-dev.2 ``` diff --git a/lib/client.dart b/lib/client.dart index 6239ead..bcb2a0f 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -29,6 +29,7 @@ class EventFlux extends EventFluxBase { bool _isExplicitDisconnect = false; StreamSubscription? _streamSubscription; ReconnectConfig? _reconnectConfig; + EventFluxStatus _status = EventFluxStatus.disconnected; int _maxAttempts = 0; int _interval = 0; String? _tag; @@ -141,6 +142,18 @@ class EventFlux extends EventFluxBase { List? files, bool multipartRequest = false, }) { + // This check prevents redundant connection requests when a connection is already in progress. + // This does not prevent reconnection attempts if autoReconnect is enabled. + + // When using `spawn`, the `_status` is `disconnected` by default. so this check will always be false. + if (_status == EventFluxStatus.connected || + _status == EventFluxStatus.connectionInitiated) { + eventFluxLog('Already Connection in Progress, Skipping redundant request', + LogEvent.info, _tag); + return; + } + _status = EventFluxStatus.connectionInitiated; + /// Set the tag for logging purposes. _tag = tag; @@ -154,6 +167,8 @@ class EventFlux extends EventFluxBase { return; } + eventFluxLog("$_status", LogEvent.info, _tag); + /// If autoReconnect is enabled, set the maximum attempts and interval based on the reconnect configuration. if (reconnectConfig != null) { _reconnectConfig = reconnectConfig; @@ -162,6 +177,7 @@ class EventFlux extends EventFluxBase { } _isExplicitDisconnect = false; + _start( type, url, @@ -252,10 +268,10 @@ class EventFlux extends EventFluxBase { Future response; if (httpClient != null) { - // use external http client + // Use external HTTP client response = httpClient.send(request); } else { - // use internal http client + // Use internal HTTP client response = _client!.send(request); } @@ -273,6 +289,7 @@ class EventFlux extends EventFluxBase { ); if (data.statusCode < 200 || data.statusCode >= 300) { + _status = EventFluxStatus.connected; String responseBody = await data.stream.bytesToString(); if (onError != null) { Map? errorDetails; @@ -313,7 +330,7 @@ class EventFlux extends EventFluxBase { return; } - ///Applying transforms and listening to it + // Applying transforms and listening to it _streamSubscription = data.stream .transform(const Utf8Decoder()) .transform(const LineSplitter()) @@ -322,7 +339,9 @@ class EventFlux extends EventFluxBase { if (dataLine.isEmpty) { /// When the data line is empty, it indicates that the complete event set has been read. /// The event is then added to the stream. - _streamController!.add(currentEventFluxData); + if (!_streamController!.isClosed) { + _streamController!.add(currentEventFluxData); + } if (logReceivedData) { eventFluxLog( currentEventFluxData.data.toString(), @@ -335,7 +354,7 @@ class EventFlux extends EventFluxBase { return; } - /// Parsing each line through the regex. + // Parsing each line through the regex. Match match = lineRegex.firstMatch(dataLine)!; var field = match.group(1); if (field!.isEmpty) { @@ -343,7 +362,7 @@ class EventFlux extends EventFluxBase { } var value = ''; if (field == 'data') { - /// If the field is data, we get the data through the substring + // If the field is data, we get the data through the substring value = dataLine.substring(5); } else { value = match.group(2) ?? ''; @@ -368,7 +387,7 @@ class EventFlux extends EventFluxBase { eventFluxLog('Stream Closed', LogEvent.info, _tag); await _stop(); - /// When the stream is closed, onClose can be called to execute a function. + // When the stream is closed, onClose can be called to execute a function. if (onConnectionClose != null) onConnectionClose(); _attemptReconnectIfNeeded( @@ -393,7 +412,7 @@ class EventFlux extends EventFluxBase { _tag, ); - /// Executes the onError function if it is not null + // Executes the onError function if it is not null if (onError != null) { onError(EventFluxException( message: error.toString(), @@ -462,6 +481,7 @@ class EventFlux extends EventFluxBase { @override Future disconnect() async { _isExplicitDisconnect = true; + _reconnectConfig = null; return await _stop(); } @@ -477,7 +497,8 @@ class EventFlux extends EventFluxBase { _client?.close(); Future.delayed(const Duration(seconds: 1), () {}); eventFluxLog('Disconnected', LogEvent.info, _tag); - return EventFluxStatus.disconnected; + _status = EventFluxStatus.disconnected; + return _status; } catch (error) { eventFluxLog('Disconnected $error', LogEvent.info, _tag); return EventFluxStatus.error; @@ -526,52 +547,66 @@ class EventFlux extends EventFluxBase { header = await _reconnectConfig!.reconnectHeader!(); } + if (isExplicitDisconnect) { + eventFluxLog("Explicit disconnection. Aborting retry attempts", + LogEvent.info, _tag); + return; // Exit early if an explicit disconnect occurred. + } + switch (_reconnectConfig!.mode) { case ReconnectMode.linear: - eventFluxLog("Trying again in ${_interval.toString()} seconds", - LogEvent.reconnect, _tag); /// It waits for the specified constant interval before attempting to reconnect. await Future.delayed(_reconnectConfig!.interval, () { - _start( - type, - url, - onSuccessCallback: onSuccessCallback, - autoReconnect: autoReconnect, - onError: onError, - header: header, - onConnectionClose: onConnectionClose, - httpClient: httpClient, - body: body, - files: files, - multipartRequest: multipartRequest, - ); + if (!isExplicitDisconnect) { + eventFluxLog("Trying again in ${_interval.toString()} seconds", + LogEvent.reconnect, _tag); + _status = EventFluxStatus.connectionInitiated; + _start( + type, + url, + onSuccessCallback: onSuccessCallback, + autoReconnect: autoReconnect, + onError: onError, + header: header, + onConnectionClose: onConnectionClose, + httpClient: httpClient, + body: body, + files: files, + multipartRequest: multipartRequest, + ); + } }); + break; + case ReconnectMode.exponential: _interval = _interval * 2; - eventFluxLog("Trying again in ${_interval.toString()} seconds", - LogEvent.reconnect, _tag); /// It waits for the specified interval before attempting to reconnect. await Future.delayed(Duration(seconds: _interval), () { - _start( - type, - url, - onSuccessCallback: onSuccessCallback, - autoReconnect: autoReconnect, - onError: onError, - header: header, - onConnectionClose: onConnectionClose, - httpClient: httpClient, - body: body, - files: files, - multipartRequest: multipartRequest, - ); - }); + if (!isExplicitDisconnect) { + eventFluxLog("Trying again in ${_interval.toString()} seconds", + LogEvent.reconnect, _tag); - /// If a onReconnect is provided, it is executed. + _status = EventFluxStatus.connectionInitiated; + _start( + type, + url, + onSuccessCallback: onSuccessCallback, + autoReconnect: autoReconnect, + onError: onError, + header: header, + onConnectionClose: onConnectionClose, + httpClient: httpClient, + body: body, + files: files, + multipartRequest: multipartRequest, + ); + } + }); + break; } - if (_reconnectConfig!.onReconnect != null) { + if (_reconnectConfig != null && _reconnectConfig?.onReconnect != null) { _reconnectConfig!.onReconnect!(); } } diff --git a/pubspec.yaml b/pubspec.yaml index 70691ad..d4ab68a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eventflux description: "Efficient handling of server-sent event streams with easy connectivity and data management." -version: 2.2.1 +version: 2.2.2-dev.2 homepage: https://gokula.dev repository: https://github.com/Imgkl/EventFlux issue_tracker: https://github.com/Imgkl/EventFlux/issues From cd3f94662eba9cb6e2ed1f7d592cdb2d84acac8f Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 12:53:27 +0100 Subject: [PATCH 2/9] fix: Interval calculation for exponential reconnect --- example/pubspec.lock | 6 +- lib/client.dart | 2 +- pubspec.yaml | 31 +--- test/client_test.dart | 362 +++++++++++++++++++++++++++++++++++++++ test/eventflux_test.dart | 12 -- test/mocks.dart | 5 + test/mocks.mocks.dart | 60 +++++++ 7 files changed, 435 insertions(+), 43 deletions(-) create mode 100644 test/client_test.dart delete mode 100644 test/eventflux_test.dart create mode 100644 test/mocks.dart create mode 100644 test/mocks.mocks.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index fff16bb..55b5367 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -47,7 +47,7 @@ packages: path: ".." relative: true source: path - version: "2.2.0" + version: "2.2.1" fake_async: dependency: transitive description: @@ -227,10 +227,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" web: dependency: transitive description: diff --git a/lib/client.dart b/lib/client.dart index bcb2a0f..f5effc4 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -580,10 +580,10 @@ class EventFlux extends EventFluxBase { break; case ReconnectMode.exponential: - _interval = _interval * 2; /// It waits for the specified interval before attempting to reconnect. await Future.delayed(Duration(seconds: _interval), () { + _interval = _interval * 2; if (!isExplicitDisconnect) { eventFluxLog("Trying again in ${_interval.toString()} seconds", LogEvent.reconnect, _tag); diff --git a/pubspec.yaml b/pubspec.yaml index d4ab68a..77643d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,8 +22,10 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.2 - -flutter: + mockito: ^5.4.4 + build_runner: ^2.4.13 + test: ^1.25.7 + fake_async: ^1.3.1 platforms: android: @@ -31,28 +33,3 @@ platforms: macos: linux: windows: - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/client_test.dart b/test/client_test.dart new file mode 100644 index 0000000..2ec3191 --- /dev/null +++ b/test/client_test.dart @@ -0,0 +1,362 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:eventflux/eventflux.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'mocks.mocks.dart'; + +void main() { + late MockHttpClientAdapter mockHttpClient; + late EventFlux eventFlux; + const testUrl = 'http://test.com/events'; + + setUp(() { + mockHttpClient = MockHttpClientAdapter(); + eventFlux = EventFlux.spawn(); + }); + + group('EventFlux', () { + for (var connectionType in EventFluxConnectionType.values) { + group('connect with $connectionType', () { + test( + 'calls onSuccessCallback with connected status and streams response data', + () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + EventFluxResponse? eventFluxResponse; + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + onSuccessCallback: (res) { + eventFluxResponse = res; + expect(res?.status, EventFluxStatus.connected); + }, + ); + + // Simulate SSE data + controller.add(utf8.encode('data:test message\n\n')); + controller.add(utf8.encode('data:test message 2\n\n')); + async.flushMicrotasks(); + + final mappedStream = eventFluxResponse?.stream?.map((e) => e.data); + expect( + mappedStream, + emitsInOrder([ + 'test message\n', + 'test message 2\n', + ])); + async.flushMicrotasks(); + }); + }); + + test('error response calls onError callback', () async { + final response = StreamedResponse( + Stream.value([]), + 404, + headers: {'content-type': 'text/event-stream'}, + reasonPhrase: 'Not Found', + ); + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + bool errorCaught = false; + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + onSuccessCallback: (_) {}, + onError: (error) { + errorCaught = true; + expect(error.statusCode, 404); + expect(error.reasonPhrase, 'Not Found'); + }, + ); + async.flushMicrotasks(); + + expect(errorCaught, true); + }); + }); + + test('reconnects with autoReconnect: true for linear mode', () async { + final controller = StreamController>(); + final controller2 = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final response2 = StreamedResponse( + controller2.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + final responses = [response, response2]; + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(responses.removeAt(0))); + + fakeAsync((async) { + Stream? mappedStream; + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + autoReconnect: true, + reconnectConfig: ReconnectConfig( + mode: ReconnectMode.linear, + interval: const Duration(milliseconds: 100), + maxAttempts: 1, + ), + onSuccessCallback: (res) { + mappedStream = res?.stream?.map((e) => e.data); + expect(res?.status, EventFluxStatus.connected); + }, + ); + async.flushMicrotasks(); + + controller.add(utf8.encode('data:test message\n\n')); + expect(mappedStream, emits('test message\n')); + controller.close(); + async.elapse(const Duration(milliseconds: 100)); + + async.flushMicrotasks(); + controller2.add(utf8.encode('data:test message 2\n\n')); + expect(mappedStream, emits('test message 2\n')); + controller2.close(); + + async.flushMicrotasks(); + }); + }); + + test('reconnects with autoReconnect: true for exponential mode', + () async { + final controller = StreamController>(); + final controller2 = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final response2 = StreamedResponse( + controller2.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + final responses = [response, response2]; + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(responses.removeAt(0))); + + fakeAsync((async) { + Stream? mappedStream; + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + autoReconnect: true, + reconnectConfig: ReconnectConfig( + mode: ReconnectMode.exponential, + interval: const Duration(seconds: 1), + maxAttempts: 3, + ), + onSuccessCallback: (res) { + mappedStream = res?.stream?.map((e) => e.data); + expect(res?.status, EventFluxStatus.connected); + }, + ); + async.flushMicrotasks(); + + controller.add(utf8.encode('data:test message\n\n')); + expect(mappedStream, emits('test message\n')); + controller.close(); + async.elapse(const Duration(seconds: 1)); + + async.flushMicrotasks(); + controller2.add(utf8.encode('data:test message 2\n\n')); + expect(mappedStream, emits('test message 2\n')); + controller2.close(); + + async.flushMicrotasks(); + }); + }); + + test('tries reconnection in linear intervals for linear mode', + () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + when(mockHttpClient.send(any)).thenAnswer( + (_) => Future.value(response), + ); + + fakeAsync((async) { + int connectionAttempts = 0; + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + autoReconnect: true, + reconnectConfig: ReconnectConfig( + mode: ReconnectMode.linear, + interval: const Duration(milliseconds: 100), + maxAttempts: 2, + onReconnect: () { + connectionAttempts++; + }, + ), + onSuccessCallback: (_) {}, + ); + + // Simulate connection close + controller.close(); + expect(connectionAttempts, 0); + async.elapse(const Duration(milliseconds: 100)); + expect(connectionAttempts, 1); + async.elapse(const Duration(milliseconds: 100)); + expect(connectionAttempts, 2); + }); + }); + + test('tries reconnection in exponential intervals for exponential mode', + () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + when(mockHttpClient.send(any)).thenAnswer( + (_) => Future.value(response), + ); + + fakeAsync((async) { + int connectionAttempts = 0; + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + autoReconnect: true, + reconnectConfig: ReconnectConfig( + mode: ReconnectMode.exponential, + interval: const Duration(seconds: 1), + maxAttempts: 3, + onReconnect: () { + connectionAttempts++; + }, + ), + onSuccessCallback: (_) {}, + ); + + controller.close(); + expect(connectionAttempts, 0); + async.elapse(const Duration(seconds: 1)); + expect(connectionAttempts, 1); + async.elapse(const Duration(seconds: 2)); + expect(connectionAttempts, 2); + async.elapse(const Duration(seconds: 4)); + expect(connectionAttempts, 3); + }); + }); + }); + } + + group('disconnect', () { + test('explicit disconnect prevents reconnection for linear mode', + () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + int reconnectAttempts = 0; + eventFlux.connect( + EventFluxConnectionType.get, + testUrl, + httpClient: mockHttpClient, + autoReconnect: true, + reconnectConfig: ReconnectConfig( + mode: ReconnectMode.linear, + interval: const Duration(milliseconds: 100), + maxAttempts: 2, + onReconnect: () { + reconnectAttempts++; + }, + ), + onSuccessCallback: (_) {}, + ); + + final status = eventFlux.disconnect(); + expect(status, completion(EventFluxStatus.disconnected)); + + async.elapse(const Duration(milliseconds: 300)); + expect(reconnectAttempts, 0); + }); + }); + + test('explicit disconnect prevents reconnection for exponential mode', + () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + int reconnectAttempts = 0; + eventFlux.connect( + EventFluxConnectionType.get, + testUrl, + httpClient: mockHttpClient, + autoReconnect: true, + reconnectConfig: ReconnectConfig( + mode: ReconnectMode.exponential, + interval: const Duration(seconds: 1), + maxAttempts: 2, + onReconnect: () { + reconnectAttempts++; + }, + ), + onSuccessCallback: (_) {}, + ); + + final status = eventFlux.disconnect(); + expect(status, completion(EventFluxStatus.disconnected)); + + async.elapse(const Duration(seconds: 3)); + expect(reconnectAttempts, 0); + }); + }); + }); + }); +} diff --git a/test/eventflux_test.dart b/test/eventflux_test.dart deleted file mode 100644 index 0f0e8ad..0000000 --- a/test/eventflux_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -// import 'package:flutter_test/flutter_test.dart'; - -// import 'package:eventflux/eventflux.dart'; - -// void main() { -// test('adds one to input values', () { -// final calculator = Calculator(); -// expect(calculator.addOne(2), 3); -// expect(calculator.addOne(-7), -6); -// expect(calculator.addOne(0), 1); -// }); -// } diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 0000000..d0bfbdd --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,5 @@ +import 'package:eventflux/http_client_adapter.dart'; +import 'package:mockito/annotations.dart'; + +@GenerateMocks([HttpClientAdapter]) +class GeneratedMocks {} diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart new file mode 100644 index 0000000..0491cc7 --- /dev/null +++ b/test/mocks.mocks.dart @@ -0,0 +1,60 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in eventflux/test/mocks.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:eventflux/http_client_adapter.dart' as _i3; +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: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// 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 _FakeStreamedResponse_0 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [HttpClientAdapter]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockHttpClientAdapter extends _i1.Mock implements _i3.HttpClientAdapter { + MockHttpClientAdapter() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method( + #send, + [request], + ), + returnValue: + _i4.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_0( + this, + Invocation.method( + #send, + [request], + ), + )), + ) as _i4.Future<_i2.StreamedResponse>); +} From 9dbb3a2c17ef49e6fd60571d03b2938ce4ca28c1 Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 16:32:22 +0100 Subject: [PATCH 3/9] test: Add tests for multipart requests --- test/client_test.dart | 105 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/test/client_test.dart b/test/client_test.dart index 2ec3191..204d174 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -279,6 +279,111 @@ void main() { }); }); }); + + test('sends MultipartRequest with files and fields', () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final multipartFile = MultipartFile.fromString('test', 'test'); + final body = {'testKey': 'testValue'}; + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + EventFluxResponse? eventFluxResponse; + fakeAsync((async) { + eventFlux.connect( + EventFluxConnectionType.get, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + files: [ + multipartFile, + ], + body: body, + onSuccessCallback: (response) { + eventFluxResponse = response; + }, + ); + + async.flushMicrotasks(); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.files.single, multipartFile); + expect(request.fields, body); + expect(eventFluxResponse?.status, EventFluxStatus.connected); + }); + }); + + test('sends multipart request with files', () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final multipartFile = MultipartFile.fromString('test', 'test'); + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + EventFluxResponse? eventFluxResponse; + fakeAsync((async) { + eventFlux.connect( + EventFluxConnectionType.get, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + files: [multipartFile], + onSuccessCallback: (response) { + eventFluxResponse = response; + }, + ); + async.flushMicrotasks(); + + expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.files.single, multipartFile); + }); + }); + + test('sends MultipartRequest with fields', () async { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final body = {'testKey': 'testValue'}; + + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + EventFluxResponse? eventFluxResponse; + fakeAsync((async) { + eventFlux.connect( + EventFluxConnectionType.get, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + body: body, + onSuccessCallback: (response) { + eventFluxResponse = response; + }, + ); + async.flushMicrotasks(); + + expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.fields, body); + }); + }); } group('disconnect', () { From 2b0fac667e18bb08fe8a81a3404c986f9aa305b1 Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 16:34:57 +0100 Subject: [PATCH 4/9] ref: Remove unnecessary async keyword --- test/client_test.dart | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/test/client_test.dart b/test/client_test.dart index 204d174..7006898 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -24,7 +24,7 @@ void main() { group('connect with $connectionType', () { test( 'calls onSuccessCallback with connected status and streams response data', - () async { + () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -63,7 +63,7 @@ void main() { }); }); - test('error response calls onError callback', () async { + test('error response calls onError callback', () { final response = StreamedResponse( Stream.value([]), 404, @@ -93,7 +93,7 @@ void main() { }); }); - test('reconnects with autoReconnect: true for linear mode', () async { + test('reconnects with autoReconnect: true for linear mode', () { final controller = StreamController>(); final controller2 = StreamController>(); final response = StreamedResponse( @@ -144,8 +144,7 @@ void main() { }); }); - test('reconnects with autoReconnect: true for exponential mode', - () async { + test('reconnects with autoReconnect: true for exponential mode', () { final controller = StreamController>(); final controller2 = StreamController>(); final response = StreamedResponse( @@ -196,8 +195,7 @@ void main() { }); }); - test('tries reconnection in linear intervals for linear mode', - () async { + test('tries reconnection in linear intervals for linear mode', () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -238,7 +236,7 @@ void main() { }); test('tries reconnection in exponential intervals for exponential mode', - () async { + () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -280,7 +278,7 @@ void main() { }); }); - test('sends MultipartRequest with files and fields', () async { + test('sends MultipartRequest with files and fields', () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -319,7 +317,7 @@ void main() { }); }); - test('sends multipart request with files', () async { + test('sends multipart request with files', () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -352,7 +350,7 @@ void main() { }); }); - test('sends MultipartRequest with fields', () async { + test('sends MultipartRequest with fields', () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -387,8 +385,7 @@ void main() { } group('disconnect', () { - test('explicit disconnect prevents reconnection for linear mode', - () async { + test('explicit disconnect prevents reconnection for linear mode', () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, @@ -426,7 +423,7 @@ void main() { }); test('explicit disconnect prevents reconnection for exponential mode', - () async { + () { final controller = StreamController>(); final response = StreamedResponse( controller.stream, From a8a3ff93fdd80315d0a05a1153908289b319feea Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 16:47:44 +0100 Subject: [PATCH 5/9] test: Add tests for headers --- test/client_test.dart | 324 ++++++++++++++++++++++++++++++------------ 1 file changed, 235 insertions(+), 89 deletions(-) diff --git a/test/client_test.dart b/test/client_test.dart index 7006898..f20439e 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -63,6 +63,152 @@ void main() { }); }); + group('adds headers', () { + final response = StreamedResponse( + Stream.value([]), + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + test('to request', () { + final headers = {'test': 'test', 'test2': 'test2'}; + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + header: headers, + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as Request; + expect(request.headers, headers); + }); + + test('to multipart request', () { + final headers = {'test': 'test', 'test2': 'test2'}; + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + header: headers, + multipartRequest: true, + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.headers, headers); + }); + + test('to multipart request with files', () { + final headers = {'test': 'test', 'test2': 'test2'}; + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + header: headers, + files: [MultipartFile.fromString('test', 'test')], + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.headers, headers); + }); + }); + + group('adds default headers', () { + final response = StreamedResponse( + Stream.value([]), + 200, + headers: {'content-type': 'text/event-stream'}, + ); + + test('to request', () { + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as Request; + expect(request.headers, { + 'Accept': 'text/event-stream', + }); + }); + }); + + test('to multipart request', () { + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.headers, { + 'Accept': 'text/event-stream', + }); + }); + }); + + test('to multipart request with files', () { + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + files: [MultipartFile.fromString('test', 'test')], + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.headers, { + 'Accept': 'text/event-stream', + }); + }); + }); + }); + test('error response calls onError callback', () { final response = StreamedResponse( Stream.value([]), @@ -276,110 +422,110 @@ void main() { expect(connectionAttempts, 3); }); }); - }); - test('sends MultipartRequest with files and fields', () { - final controller = StreamController>(); - final response = StreamedResponse( - controller.stream, - 200, - headers: {'content-type': 'text/event-stream'}, - ); - final multipartFile = MultipartFile.fromString('test', 'test'); - final body = {'testKey': 'testValue'}; + test('sends MultipartRequest with files and fields', () { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final multipartFile = MultipartFile.fromString('test', 'test'); + final body = {'testKey': 'testValue'}; - when(mockHttpClient.send(any)) - .thenAnswer((_) => Future.value(response)); + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); - EventFluxResponse? eventFluxResponse; - fakeAsync((async) { - eventFlux.connect( - EventFluxConnectionType.get, - testUrl, - httpClient: mockHttpClient, - multipartRequest: true, - files: [ - multipartFile, - ], - body: body, - onSuccessCallback: (response) { - eventFluxResponse = response; - }, - ); + EventFluxResponse? eventFluxResponse; + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + files: [ + multipartFile, + ], + body: body, + onSuccessCallback: (response) { + eventFluxResponse = response; + }, + ); - async.flushMicrotasks(); + async.flushMicrotasks(); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.files.single, multipartFile); - expect(request.fields, body); - expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.files.single, multipartFile); + expect(request.fields, body); + expect(eventFluxResponse?.status, EventFluxStatus.connected); + }); }); - }); - test('sends multipart request with files', () { - final controller = StreamController>(); - final response = StreamedResponse( - controller.stream, - 200, - headers: {'content-type': 'text/event-stream'}, - ); - final multipartFile = MultipartFile.fromString('test', 'test'); + test('sends multipart request with files', () { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final multipartFile = MultipartFile.fromString('test', 'test'); - when(mockHttpClient.send(any)) - .thenAnswer((_) => Future.value(response)); + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); - EventFluxResponse? eventFluxResponse; - fakeAsync((async) { - eventFlux.connect( - EventFluxConnectionType.get, - testUrl, - httpClient: mockHttpClient, - multipartRequest: true, - files: [multipartFile], - onSuccessCallback: (response) { - eventFluxResponse = response; - }, - ); - async.flushMicrotasks(); + EventFluxResponse? eventFluxResponse; + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + files: [multipartFile], + onSuccessCallback: (response) { + eventFluxResponse = response; + }, + ); + async.flushMicrotasks(); - expect(eventFluxResponse?.status, EventFluxStatus.connected); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.files.single, multipartFile); + expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.files.single, multipartFile); + }); }); - }); - test('sends MultipartRequest with fields', () { - final controller = StreamController>(); - final response = StreamedResponse( - controller.stream, - 200, - headers: {'content-type': 'text/event-stream'}, - ); - final body = {'testKey': 'testValue'}; + test('sends MultipartRequest with fields', () { + final controller = StreamController>(); + final response = StreamedResponse( + controller.stream, + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final body = {'testKey': 'testValue'}; - when(mockHttpClient.send(any)) - .thenAnswer((_) => Future.value(response)); + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); - EventFluxResponse? eventFluxResponse; - fakeAsync((async) { - eventFlux.connect( - EventFluxConnectionType.get, - testUrl, - httpClient: mockHttpClient, - multipartRequest: true, - body: body, - onSuccessCallback: (response) { - eventFluxResponse = response; - }, - ); - async.flushMicrotasks(); + EventFluxResponse? eventFluxResponse; + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + httpClient: mockHttpClient, + multipartRequest: true, + body: body, + onSuccessCallback: (response) { + eventFluxResponse = response; + }, + ); + async.flushMicrotasks(); - expect(eventFluxResponse?.status, EventFluxStatus.connected); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.fields, body); + expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.fields, body); + }); }); }); } From 2dbed92ddbfcef092e44cf1f46d58d6337ae5ebd Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 16:54:11 +0100 Subject: [PATCH 6/9] test: Add tests for correctly passing Url --- test/client_test.dart | 118 +++++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 25 deletions(-) diff --git a/test/client_test.dart b/test/client_test.dart index f20439e..87ee1eb 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -63,6 +63,74 @@ void main() { }); }); + group('sets url', () { + final response = StreamedResponse( + Stream.value([]), + 200, + headers: {'content-type': 'text/event-stream'}, + ); + final testUri = Uri.parse(testUrl); + + test('to request', () { + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUri.toString(), + httpClient: mockHttpClient, + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as Request; + expect(request.url, testUri); + }); + + test('to multipart request', () { + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUri.toString(), + httpClient: mockHttpClient, + multipartRequest: true, + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.url, testUri); + }); + + test('to multipart request with files', () { + when(mockHttpClient.send(any)) + .thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUri.toString(), + httpClient: mockHttpClient, + files: [MultipartFile.fromString('test', 'test')], + onSuccessCallback: (_) {}, + ); + async.flushMicrotasks(); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.url, testUri); + }); + }); + group('adds headers', () { final response = StreamedResponse( Stream.value([]), @@ -155,12 +223,12 @@ void main() { onSuccessCallback: (_) {}, ); async.flushMicrotasks(); + }); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as Request; - expect(request.headers, { - 'Accept': 'text/event-stream', - }); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as Request; + expect(request.headers, { + 'Accept': 'text/event-stream', }); }); @@ -177,12 +245,12 @@ void main() { onSuccessCallback: (_) {}, ); async.flushMicrotasks(); + }); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.headers, { - 'Accept': 'text/event-stream', - }); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.headers, { + 'Accept': 'text/event-stream', }); }); @@ -199,12 +267,12 @@ void main() { onSuccessCallback: (_) {}, ); async.flushMicrotasks(); + }); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.headers, { - 'Accept': 'text/event-stream', - }); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.headers, { + 'Accept': 'text/event-stream', }); }); }); @@ -487,12 +555,12 @@ void main() { }, ); async.flushMicrotasks(); - - expect(eventFluxResponse?.status, EventFluxStatus.connected); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.files.single, multipartFile); }); + + expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.files.single, multipartFile); }); test('sends MultipartRequest with fields', () { @@ -520,12 +588,12 @@ void main() { }, ); async.flushMicrotasks(); - - expect(eventFluxResponse?.status, EventFluxStatus.connected); - final call = verify(mockHttpClient.send(captureAny))..called(1); - final request = call.captured.single as MultipartRequest; - expect(request.fields, body); }); + + expect(eventFluxResponse?.status, EventFluxStatus.connected); + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as MultipartRequest; + expect(request.fields, body); }); }); } From f048a93e8b78907c127a164e315926bc18caccc2 Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 16:55:48 +0100 Subject: [PATCH 7/9] test: Add tests for get and post method --- test/client_test.dart | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/client_test.dart b/test/client_test.dart index 87ee1eb..084a108 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -20,6 +20,42 @@ void main() { }); group('EventFlux', () { + test('connect with GET', () { + final response = StreamedResponse(Stream.value([]), 200); + when(mockHttpClient.send(any)).thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + EventFluxConnectionType.get, + testUrl, + httpClient: mockHttpClient, + onSuccessCallback: (_) {}, + ); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as Request; + expect(request.method, 'GET'); + }); + + test('connect with POST', () { + final response = StreamedResponse(Stream.value([]), 200); + when(mockHttpClient.send(any)).thenAnswer((_) => Future.value(response)); + + fakeAsync((async) { + eventFlux.connect( + EventFluxConnectionType.post, + testUrl, + httpClient: mockHttpClient, + onSuccessCallback: (_) {}, + ); + }); + + final call = verify(mockHttpClient.send(captureAny))..called(1); + final request = call.captured.single as Request; + expect(request.method, 'POST'); + }); + for (var connectionType in EventFluxConnectionType.values) { group('connect with $connectionType', () { test( From 91007dba7168946fcaa87dfe84b256c9eb70ca24 Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Fri, 22 Nov 2024 19:35:17 +0100 Subject: [PATCH 8/9] feat: Add browser support --- .github/workflows/continuous-integration.yaml | 21 ++++++++ example/pubspec.lock | 18 ++++++- lib/client.dart | 11 ++-- pubspec.yaml | 1 + test/integration/eventflux_test_browser.dart | 52 +++++++++++++++++++ test/integration/eventflux_test_vm.dart | 52 +++++++++++++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 test/integration/eventflux_test_browser.dart create mode 100644 test/integration/eventflux_test_vm.dart diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index 3d061ca..c8b50ec 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -18,3 +18,24 @@ jobs: with: flutter_channel: stable min_coverage: 0 + + test-clients: + runs-on: ubuntu-latest + steps: + - name: Git Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install Dependencies + run: flutter pub get + + - name: Run Integration Tests VM + run: flutter test test/integration/eventflux_test_vm.dart + + - name: Run Integration Tests Browser + run: flutter test test/integration/eventflux_test_browser.dart --platform chrome diff --git a/example/pubspec.lock b/example/pubspec.lock index 55b5367..c694bfd 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -47,7 +47,7 @@ packages: path: ".." relative: true source: path - version: "2.2.1" + version: "2.2.2-dev.2" fake_async: dependency: transitive description: @@ -56,6 +56,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" flutter: dependency: "direct main" description: flutter diff --git a/lib/client.dart b/lib/client.dart index f5effc4..dc07459 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -12,6 +12,8 @@ import 'package:eventflux/models/exception.dart'; import 'package:eventflux/models/reconnect.dart'; import 'package:eventflux/models/response.dart'; import 'package:eventflux/utils.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; /// A class for managing event-driven data streams using Server-Sent Events (SSE). @@ -24,7 +26,8 @@ class EventFlux extends EventFluxBase { static final EventFlux _instance = EventFlux._(); static EventFlux get instance => _instance; - Client? _client; + @visibleForTesting + Client? client; StreamController? _streamController; bool _isExplicitDisconnect = false; StreamSubscription? _streamSubscription; @@ -214,7 +217,7 @@ class EventFlux extends EventFluxBase { /// Create a new HTTP client based on the platform /// Uses and internal http client if no http client adapter is present if (httpClient == null) { - _client = Client(); + client = kIsWeb ? FetchClient() : Client(); } /// Set `_isExplicitDisconnect` to `false` before connecting. @@ -272,7 +275,7 @@ class EventFlux extends EventFluxBase { response = httpClient.send(request); } else { // Use internal HTTP client - response = _client!.send(request); + response = client!.send(request); } response.then((data) async { @@ -494,7 +497,7 @@ class EventFlux extends EventFluxBase { try { _streamSubscription?.cancel(); _streamController?.close(); - _client?.close(); + client?.close(); Future.delayed(const Duration(seconds: 1), () {}); eventFluxLog('Disconnected', LogEvent.info, _tag); _status = EventFluxStatus.disconnected; diff --git a/pubspec.yaml b/pubspec.yaml index 77643d1..565b2a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ environment: flutter: ">=1.17.0" dependencies: + fetch_client: ^1.1.2 flutter: sdk: flutter http: ^1.2.2 diff --git a/test/integration/eventflux_test_browser.dart b/test/integration/eventflux_test_browser.dart new file mode 100644 index 0000000..6a07349 --- /dev/null +++ b/test/integration/eventflux_test_browser.dart @@ -0,0 +1,52 @@ +@TestOn('browser') +import 'dart:async'; + +import 'package:eventflux/eventflux.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +/// Run this with `flutter test --platform chrome test/integration/eventflux_test_browser.dart` +void main() { + late EventFlux eventFlux; + + setUp(() { + eventFlux = EventFlux.spawn(); + }); + + for (final connectionType in [ + EventFluxConnectionType.get, + EventFluxConnectionType.post, + ]) { + test( + 'uses FetchClient in browser environment for $connectionType', + () { + final events = []; + final completer = Completer(); + const testUrl = 'https://localhost:4567'; + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + onSuccessCallback: (response) { + response?.stream?.listen( + (event) { + events.add(event.data); + if (events.length >= 3) { + completer.complete(); + } + }, + onError: (error) => completer.completeError(error), + ); + }, + onError: (error) => completer.completeError(error), + ); + }); + + expect(eventFlux.client, isA()); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); + } +} diff --git a/test/integration/eventflux_test_vm.dart b/test/integration/eventflux_test_vm.dart new file mode 100644 index 0000000..3dedb81 --- /dev/null +++ b/test/integration/eventflux_test_vm.dart @@ -0,0 +1,52 @@ +@TestOn('vm') +import 'dart:async'; + +import 'package:eventflux/eventflux.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +/// Run this with `flutter test test/integration/eventflux_test_vm.dart` +void main() { + late EventFlux eventFlux; + + setUp(() { + eventFlux = EventFlux.spawn(); + }); + + for (final connectionType in [ + EventFluxConnectionType.get, + EventFluxConnectionType.post, + ]) { + test( + 'uses Client in non-browser environment for $connectionType', + () { + final events = []; + final completer = Completer(); + const testUrl = 'https://localhost:4567'; + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + onSuccessCallback: (response) { + response?.stream?.listen( + (event) { + events.add(event.data); + if (events.length >= 3) { + completer.complete(); + } + }, + onError: (error) => completer.completeError(error), + ); + }, + onError: (error) => completer.completeError(error), + ); + }); + + expect(eventFlux.client, isA()); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); + } +} From 4a3414d9b5c681d3b75b24f23d9546e7c30a3c94 Mon Sep 17 00:00:00 2001 From: Peter Trost Date: Wed, 27 Nov 2024 16:41:16 +0100 Subject: [PATCH 9/9] feat: Add Browser support --- .github/workflows/continuous-integration.yaml | 2 +- CHANGELOG.md | 5 + README.md | 3 +- lib/client.dart | 13 +- lib/extensions/fetch_client_extension.dart | 16 +++ lib/models/web_config/redirect_policy.dart | 24 ++++ lib/models/web_config/request_cache.dart | 68 ++++++++++ .../web_config/request_credentials.dart | 27 ++++ lib/models/web_config/request_mode.dart | 46 +++++++ .../web_config/request_referrer_policy.dart | 35 ++++++ lib/models/web_config/web_config.dart | 95 ++++++++++++++ pubspec.yaml | 2 +- test/browser/client_browser_test.dart | 119 ++++++++++++++++++ test/browser/fetch_client_extension_test.dart | 115 +++++++++++++++++ ...tflux_test_vm.dart => client_vm_test.dart} | 2 +- test/integration/eventflux_test_browser.dart | 52 -------- .../web_config/redirect_policy_test.dart | 28 +++++ .../models/web_config/request_cache_test.dart | 49 ++++++++ .../web_config/request_credentials_test.dart | 28 +++++ test/models/web_config/request_mode_test.dart | 42 +++++++ .../request_referrer_policy_test.dart | 66 ++++++++++ test/models/web_config/web_config_test.dart | 75 +++++++++++ 22 files changed, 854 insertions(+), 58 deletions(-) create mode 100644 lib/extensions/fetch_client_extension.dart create mode 100644 lib/models/web_config/redirect_policy.dart create mode 100644 lib/models/web_config/request_cache.dart create mode 100644 lib/models/web_config/request_credentials.dart create mode 100644 lib/models/web_config/request_mode.dart create mode 100644 lib/models/web_config/request_referrer_policy.dart create mode 100644 lib/models/web_config/web_config.dart create mode 100644 test/browser/client_browser_test.dart create mode 100644 test/browser/fetch_client_extension_test.dart rename test/{integration/eventflux_test_vm.dart => client_vm_test.dart} (94%) delete mode 100644 test/integration/eventflux_test_browser.dart create mode 100644 test/models/web_config/redirect_policy_test.dart create mode 100644 test/models/web_config/request_cache_test.dart create mode 100644 test/models/web_config/request_credentials_test.dart create mode 100644 test/models/web_config/request_mode_test.dart create mode 100644 test/models/web_config/request_referrer_policy_test.dart create mode 100644 test/models/web_config/web_config_test.dart diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index c8b50ec..43f954b 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -38,4 +38,4 @@ jobs: run: flutter test test/integration/eventflux_test_vm.dart - name: Run Integration Tests Browser - run: flutter test test/integration/eventflux_test_browser.dart --platform chrome + run: flutter test test/browser --platform chrome diff --git a/CHANGELOG.md b/CHANGELOG.md index 4237e4a..0363057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog 📝 +### v2.3.0-dev.1 🛠️ +This release adds web support 🚀 +- Added `webConfig` parameter to the `connect` method. + - This allows you to configure the web client. + - Refer README for more info. ### v2.2.2-dev.2 🛠️ - Potential fix for multiple connection issue when using single instance method diff --git a/README.md b/README.md index 7e965e8..0d76690 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ EventFlux is a Dart package designed for efficient handling of server-sent event ## Supported Platforms | Android | iOS | Web | MacOS | Windows | Linux | | ------ | ---- | ---- | ----- | ------- | ----- | -| ✅|✅|🏗️|✅|❓|❓| +| ✅|✅|✅|✅|❓|❓| *Pssst... see those question marks? That's your cue, tech adventurers! Dive in, test, and tell me all about it.* 🚀🛠️ @@ -211,6 +211,7 @@ Connects to a server-sent event stream. | `tag` | `String` | Optional tag for debugging. | - | | `logReceivedData` | `bool` | Whether to log received data. | `false` | | `httpClient` | `HttpClientAdapter?` | Optional Http Client Adapter to allow usage of different http clients. | - | +| `webConfig` | `WebConfig?` | Allows configuring the web client. Ignored for non-web platforms. | - |  
diff --git a/lib/client.dart b/lib/client.dart index dc07459..0c4b461 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -5,14 +5,15 @@ import 'dart:async'; import 'dart:convert'; import 'package:eventflux/enum.dart'; +import 'package:eventflux/extensions/fetch_client_extension.dart'; import 'package:eventflux/http_client_adapter.dart'; import 'package:eventflux/models/base.dart'; import 'package:eventflux/models/data.dart'; import 'package:eventflux/models/exception.dart'; import 'package:eventflux/models/reconnect.dart'; import 'package:eventflux/models/response.dart'; +import 'package:eventflux/models/web_config/web_config.dart'; import 'package:eventflux/utils.dart'; -import 'package:fetch_client/fetch_client.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; @@ -144,7 +145,12 @@ class EventFlux extends EventFluxBase { bool logReceivedData = false, List? files, bool multipartRequest = false, + + /// Optional web config to be used for the connection. Must be provided on web. + /// Will be ignored on non-web platforms. + WebConfig? webConfig, }) { + assert(!(kIsWeb && webConfig == null), 'WebConfig must be provided on web'); // This check prevents redundant connection requests when a connection is already in progress. // This does not prevent reconnection attempts if autoReconnect is enabled. @@ -194,6 +200,7 @@ class EventFlux extends EventFluxBase { logReceivedData: logReceivedData, files: files, multipartRequest: multipartRequest, + webConfig: webConfig, ); } @@ -212,12 +219,14 @@ class EventFlux extends EventFluxBase { bool logReceivedData = false, List? files, bool multipartRequest = false, + WebConfig? webConfig, }) { /// Initalise variables /// Create a new HTTP client based on the platform /// Uses and internal http client if no http client adapter is present if (httpClient == null) { - client = kIsWeb ? FetchClient() : Client(); + client = + kIsWeb ? FetchClientExtension.fromWebConfig(webConfig!) : Client(); } /// Set `_isExplicitDisconnect` to `false` before connecting. diff --git a/lib/extensions/fetch_client_extension.dart b/lib/extensions/fetch_client_extension.dart new file mode 100644 index 0000000..fc64e58 --- /dev/null +++ b/lib/extensions/fetch_client_extension.dart @@ -0,0 +1,16 @@ +import 'package:eventflux/models/web_config/web_config.dart'; +import 'package:fetch_client/fetch_client.dart'; + +extension FetchClientExtension on FetchClient { + static FetchClient fromWebConfig(WebConfig webConfig) { + return FetchClient( + mode: webConfig.mode.toRequestMode(), + credentials: webConfig.credentials.toRequestCredentials(), + cache: webConfig.cache.toRequestCache(), + referrer: webConfig.referrer, + referrerPolicy: webConfig.referrerPolicy.toRequestReferrerPolicy(), + redirectPolicy: webConfig.redirectPolicy.toRedirectPolicy(), + streamRequests: webConfig.streamRequests, + ); + } +} diff --git a/lib/models/web_config/redirect_policy.dart b/lib/models/web_config/redirect_policy.dart new file mode 100644 index 0000000..3ea9a32 --- /dev/null +++ b/lib/models/web_config/redirect_policy.dart @@ -0,0 +1,24 @@ +// This code is adapted from fetch_client package +// Copyright (c) 2023-2024 Yaroslav Vorobev and contributors +// Licensed under the MIT License + +import 'package:fetch_client/fetch_client.dart'; + +/// How requests should handle redirects. +enum WebConfigRedirectPolicy { + /// Default policy - always follow redirects. + /// If redirect occurs the only way to know about it is via response properties. + alwaysFollow, + + /// Probe via HTTP `GET` request. + probe, + + /// Same as [probe] but using `HEAD` method. + probeHead; + + RedirectPolicy toRedirectPolicy() => switch (this) { + WebConfigRedirectPolicy.alwaysFollow => RedirectPolicy.alwaysFollow, + WebConfigRedirectPolicy.probe => RedirectPolicy.probe, + WebConfigRedirectPolicy.probeHead => RedirectPolicy.probeHead, + }; +} diff --git a/lib/models/web_config/request_cache.dart b/lib/models/web_config/request_cache.dart new file mode 100644 index 0000000..46047b0 --- /dev/null +++ b/lib/models/web_config/request_cache.dart @@ -0,0 +1,68 @@ +// This code is adapted from fetch_client package +// Copyright (c) 2023-2024 Yaroslav Vorobev and contributors +// Licensed under the MIT License + +import 'package:eventflux/models/web_config/request_mode.dart'; +import 'package:fetch_client/fetch_client.dart'; + +/// Controls how requests will interact with the browser's HTTP cache. +enum WebConfigRequestCache { + /// The browser looks for a matching request in its HTTP cache. + /// + /// * If there is a match and it is fresh, it will be returned from the cache. + /// * If there is a match but it is stale, the browser will make + /// a conditional request to the remote server. If the server indicates + /// that the resource has not changed, it will be returned from the cache. + /// Otherwise the resource will be downloaded from the server and + /// the cache will be updated. + /// * If there is no match, the browser will make a normal request, + /// and will update the cache with the downloaded resource. + byDefault, + + /// The browser fetches the resource from the remote server + /// without first looking in the cache, and will not update the cache + /// with the downloaded resource. + noStore, + + /// The browser fetches the resource from the remote server + /// without first looking in the cache, but then will update the cache + /// with the downloaded resource. + reload, + + /// The browser looks for a matching request in its HTTP cache. + /// + /// * If there is a match, fresh or stale, the browser will make + /// a conditional request to the remote server. If the server indicates + /// that the resource has not changed, it will be returned from the cache. + /// Otherwise the resource will be downloaded from the server and + /// the cache will be updated. + /// * If there is no match, the browser will make a normal request, + /// and will update the cache with the downloaded resource. + noCache, + + /// The browser looks for a matching request in its HTTP cache. + /// + /// * If there is a match, fresh or stale, it will be returned from the cache. + /// * If there is no match, the browser will make a normal request, + /// and will update the cache with the downloaded resource. + forceCache, + + /// The browser looks for a matching request in its HTTP cache. + /// + /// * If there is a match, fresh or stale, it will be returned from the cache. + /// * If there is no match, the browser will respond + /// with a 504 Gateway timeout status. + /// + /// The [onlyIfCached] mode can only be used if the request's mode + /// is [WebConfigRequestMode.sameOrigin]. + onlyIfCached; + + RequestCache toRequestCache() => switch (this) { + WebConfigRequestCache.byDefault => RequestCache.byDefault, + WebConfigRequestCache.noStore => RequestCache.noStore, + WebConfigRequestCache.reload => RequestCache.reload, + WebConfigRequestCache.noCache => RequestCache.noCache, + WebConfigRequestCache.forceCache => RequestCache.forceCache, + WebConfigRequestCache.onlyIfCached => RequestCache.onlyIfCached, + }; +} diff --git a/lib/models/web_config/request_credentials.dart b/lib/models/web_config/request_credentials.dart new file mode 100644 index 0000000..a9622cd --- /dev/null +++ b/lib/models/web_config/request_credentials.dart @@ -0,0 +1,27 @@ +// This code is adapted from fetch_client package +// Copyright (c) 2023-2024 Yaroslav Vorobev and contributors +// Licensed under the MIT License + +import 'package:fetch_client/fetch_client.dart'; + +/// Controls what browsers do with credentials (cookies, HTTP authentication +/// entries, and TLS client certificates). +enum WebConfigRequestCredentials { + /// Tells browsers to include credentials with requests to same-origin URLs, + /// and use any credentials sent back in responses from same-origin URLs. + sameOrigin, + + /// Tells browsers to exclude credentials from the request, and ignore + /// any credentials sent back in the response (e.g., any Set-Cookie header). + omit, + + /// Tells browsers to include credentials in both same- and cross-origin + /// requests, and always use any credentials sent back in responses. + cors; + + RequestCredentials toRequestCredentials() => switch (this) { + WebConfigRequestCredentials.sameOrigin => RequestCredentials.sameOrigin, + WebConfigRequestCredentials.omit => RequestCredentials.omit, + WebConfigRequestCredentials.cors => RequestCredentials.cors, + }; +} diff --git a/lib/models/web_config/request_mode.dart b/lib/models/web_config/request_mode.dart new file mode 100644 index 0000000..945fdba --- /dev/null +++ b/lib/models/web_config/request_mode.dart @@ -0,0 +1,46 @@ +// This code is adapted from fetch_client package +// Copyright (c) 2023-2024 Yaroslav Vorobev and contributors +// Licensed under the MIT License + +import 'package:fetch_client/fetch_client.dart'; + +/// The mode used to determine if cross-origin requests lead to valid responses, +/// and which properties of the response are readable. +enum WebConfigRequestMode { + /// If a request is made to another origin with this mode set, + /// the result is an error. You could use this to ensure that + /// a request is always being made to your origin. + sameOrigin, + + /// Prevents the method from being anything other than `HEAD`, `GET` or `POST`, + /// and the headers from being anything other than simple headers. + /// If any `ServiceWorkers` intercept these requests, they may not add + /// or override any headers except for those that are simple headers. + /// In addition, JavaScript may not access any properties of the resulting + /// Response. This ensures that ServiceWorkers do not affect the semantics + /// of the Web and prevents security and privacy issues arising from leaking + /// data across domains. + noCors, + + /// Allows cross-origin requests, for example to access various APIs + /// offered by 3rd party vendors. These are expected to adhere to + /// the CORS protocol. Only a limited set of headers are exposed + /// in the Response, but the body is readable. + cors, + + /// A mode for supporting navigation. The navigate value is intended + /// to be used only by HTML navigation. A navigate request + /// is created only while navigating between documents. + navigate, + + /// A special mode used only when establishing a WebSocket connection. + webSocket; + + RequestMode toRequestMode() => switch (this) { + WebConfigRequestMode.sameOrigin => RequestMode.sameOrigin, + WebConfigRequestMode.noCors => RequestMode.noCors, + WebConfigRequestMode.cors => RequestMode.cors, + WebConfigRequestMode.navigate => RequestMode.navigate, + WebConfigRequestMode.webSocket => RequestMode.webSocket, + }; +} diff --git a/lib/models/web_config/request_referrer_policy.dart b/lib/models/web_config/request_referrer_policy.dart new file mode 100644 index 0000000..8220460 --- /dev/null +++ b/lib/models/web_config/request_referrer_policy.dart @@ -0,0 +1,35 @@ +// This code is adapted from fetch_client package +// Copyright (c) 2023-2024 Yaroslav Vorobev and contributors +// Licensed under the MIT License + +import 'package:fetch_client/fetch_client.dart'; + +/// Specifies the referrer policy to use for the request. +enum WebConfigRequestReferrerPolicy { + strictOriginWhenCrossOrigin, + noReferrer, + noReferrerWhenDowngrade, + sameOrigin, + origin, + strictOrigin, + originWhenCrossOrigin, + unsafeUrl; + + RequestReferrerPolicy toRequestReferrerPolicy() => switch (this) { + WebConfigRequestReferrerPolicy.strictOriginWhenCrossOrigin => + RequestReferrerPolicy.strictOriginWhenCrossOrigin, + WebConfigRequestReferrerPolicy.noReferrer => + RequestReferrerPolicy.noReferrer, + WebConfigRequestReferrerPolicy.noReferrerWhenDowngrade => + RequestReferrerPolicy.noReferrerWhenDowngrade, + WebConfigRequestReferrerPolicy.sameOrigin => + RequestReferrerPolicy.sameOrigin, + WebConfigRequestReferrerPolicy.origin => RequestReferrerPolicy.origin, + WebConfigRequestReferrerPolicy.strictOrigin => + RequestReferrerPolicy.strictOrigin, + WebConfigRequestReferrerPolicy.originWhenCrossOrigin => + RequestReferrerPolicy.originWhenCrossOrigin, + WebConfigRequestReferrerPolicy.unsafeUrl => + RequestReferrerPolicy.unsafeUrl, + }; +} diff --git a/lib/models/web_config/web_config.dart b/lib/models/web_config/web_config.dart new file mode 100644 index 0000000..c66fd28 --- /dev/null +++ b/lib/models/web_config/web_config.dart @@ -0,0 +1,95 @@ +// This code is adapted from fetch_client package +// Copyright (c) 2023-2024 Yaroslav Vorobev and contributors +// Licensed under the MIT License + +import 'redirect_policy.dart'; +import 'request_cache.dart'; +import 'request_credentials.dart'; +import 'request_mode.dart'; +import 'request_referrer_policy.dart'; + +/// Configuration for web requests. +class WebConfig { + /// Create a new web configuration. + WebConfig({ + this.mode = WebConfigRequestMode.noCors, + this.credentials = WebConfigRequestCredentials.sameOrigin, + this.cache = WebConfigRequestCache.byDefault, + this.referrer = '', + this.referrerPolicy = + WebConfigRequestReferrerPolicy.strictOriginWhenCrossOrigin, + this.redirectPolicy = WebConfigRedirectPolicy.alwaysFollow, + this.streamRequests = false, + }); + + /// The request mode. + final WebConfigRequestMode mode; + + /// The credentials mode, defines what browsers do with credentials. + final WebConfigRequestCredentials credentials; + + /// The cache mode which controls how requests will interact with + /// the browser's HTTP cache. + final WebConfigRequestCache cache; + + /// The referrer. + /// This can be a same-origin URL, `about:client`, or an empty string. + final String referrer; + + /// The referrer policy. + final WebConfigRequestReferrerPolicy referrerPolicy; + + /// The redirect policy, defines how client should handle redirects. + final WebConfigRedirectPolicy redirectPolicy; + + /// Whether to use streaming for requests. + /// + /// **NOTICE**: This feature is supported only in __Chromium 105+__ based browsers + /// and requires server to be HTTP/2 or HTTP/3. + final bool streamRequests; + + /// Creates a copy of this configuration with the given fields replaced with the new values. + WebConfig copyWith({ + WebConfigRequestMode? mode, + WebConfigRequestCredentials? credentials, + WebConfigRequestCache? cache, + String? referrer, + WebConfigRequestReferrerPolicy? referrerPolicy, + WebConfigRedirectPolicy? redirectPolicy, + bool? streamRequests, + }) { + return WebConfig( + mode: mode ?? this.mode, + credentials: credentials ?? this.credentials, + cache: cache ?? this.cache, + referrer: referrer ?? this.referrer, + referrerPolicy: referrerPolicy ?? this.referrerPolicy, + redirectPolicy: redirectPolicy ?? this.redirectPolicy, + streamRequests: streamRequests ?? this.streamRequests, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is WebConfig && + other.mode == mode && + other.credentials == credentials && + other.cache == cache && + other.referrer == referrer && + other.referrerPolicy == referrerPolicy && + other.redirectPolicy == redirectPolicy && + other.streamRequests == streamRequests; + } + + @override + int get hashCode => Object.hash( + mode, + credentials, + cache, + referrer, + referrerPolicy, + redirectPolicy, + streamRequests, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 565b2a7..3f80d0e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eventflux description: "Efficient handling of server-sent event streams with easy connectivity and data management." -version: 2.2.2-dev.2 +version: 2.3.0-dev.1 homepage: https://gokula.dev repository: https://github.com/Imgkl/EventFlux issue_tracker: https://github.com/Imgkl/EventFlux/issues diff --git a/test/browser/client_browser_test.dart b/test/browser/client_browser_test.dart new file mode 100644 index 0000000..b1180dd --- /dev/null +++ b/test/browser/client_browser_test.dart @@ -0,0 +1,119 @@ +@TestOn('browser') +import 'dart:async'; + +import 'package:eventflux/eventflux.dart'; +import 'package:eventflux/models/web_config/redirect_policy.dart'; +import 'package:eventflux/models/web_config/request_cache.dart'; +import 'package:eventflux/models/web_config/request_credentials.dart'; +import 'package:eventflux/models/web_config/request_mode.dart'; +import 'package:eventflux/models/web_config/request_referrer_policy.dart'; +import 'package:eventflux/models/web_config/web_config.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +/// Run this with `flutter test --platform chrome test/browser/eventflux_test_browser.dart` +void main() { + late EventFlux eventFlux; + + setUp(() { + eventFlux = EventFlux.spawn(); + }); + + for (final connectionType in [ + EventFluxConnectionType.get, + EventFluxConnectionType.post, + ]) { + test( + 'uses FetchClient in browser environment with correct config for $connectionType', + () { + final events = []; + final completer = Completer(); + const testUrl = 'https://localhost:4567'; + + fakeAsync((async) { + eventFlux.connect( + connectionType, + testUrl, + webConfig: WebConfig( + mode: WebConfigRequestMode.sameOrigin, + credentials: WebConfigRequestCredentials.cors, + cache: WebConfigRequestCache.noCache, + referrer: 'https://localhost:4567', + referrerPolicy: WebConfigRequestReferrerPolicy.noReferrer, + redirectPolicy: WebConfigRedirectPolicy.probe, + streamRequests: true, + ), + onSuccessCallback: (response) { + response?.stream?.listen( + (event) { + events.add(event.data); + if (events.length >= 3) { + completer.complete(); + } + }, + onError: (error) => completer.completeError(error), + ); + }, + onError: (error) => completer.completeError(error), + ); + }); + + expect( + eventFlux.client, + isA() + .having( + (c) => c.mode, + 'mode', + RequestMode.sameOrigin, + ) + .having( + (c) => c.credentials, + 'credentials', + RequestCredentials.cors, + ) + .having( + (c) => c.cache, + 'cache', + RequestCache.noCache, + ) + .having( + (c) => c.referrer, + 'referrer', + 'https://localhost:4567', + ) + .having( + (c) => c.referrerPolicy, + 'referrerPolicy', + RequestReferrerPolicy.noReferrer, + ) + .having( + (c) => c.redirectPolicy, + 'redirectPolicy', + RedirectPolicy.probe, + ) + .having( + (c) => c.streamRequests, + 'streamRequests', + true, + ), + ); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); + } + + test('throws Assertion error if webConfig is not provided', () { + expect( + () => fakeAsync((async) { + eventFlux.connect( + EventFluxConnectionType.get, + 'https://localhost:4567', + onSuccessCallback: (_) {}, + onError: (_) {}, + ); + }), + throwsA(isA()), + ); + }); +} diff --git a/test/browser/fetch_client_extension_test.dart b/test/browser/fetch_client_extension_test.dart new file mode 100644 index 0000000..5b1dded --- /dev/null +++ b/test/browser/fetch_client_extension_test.dart @@ -0,0 +1,115 @@ +@TestOn('browser') +import 'package:eventflux/extensions/fetch_client_extension.dart'; +import 'package:eventflux/models/web_config/redirect_policy.dart'; +import 'package:eventflux/models/web_config/request_cache.dart'; +import 'package:eventflux/models/web_config/request_credentials.dart'; +import 'package:eventflux/models/web_config/request_mode.dart'; +import 'package:eventflux/models/web_config/request_referrer_policy.dart'; +import 'package:eventflux/models/web_config/web_config.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +/// Run this with `flutter test test/browser/fetch_client_extensions_test.dart` +void main() { + group('FetchClientExtension', () { + test('fromWebConfig creates FetchClient with correct parameters', () { + final webConfig = WebConfig( + mode: WebConfigRequestMode.sameOrigin, + credentials: WebConfigRequestCredentials.cors, + cache: WebConfigRequestCache.noCache, + referrer: 'https://test.com', + referrerPolicy: WebConfigRequestReferrerPolicy.noReferrer, + redirectPolicy: WebConfigRedirectPolicy.probe, + streamRequests: true, + ); + + final result = FetchClientExtension.fromWebConfig(webConfig); + + expect( + result, + isA() + .having( + (c) => c.mode, + 'mode', + RequestMode.sameOrigin, + ) + .having( + (c) => c.credentials, + 'credentials', + RequestCredentials.cors, + ) + .having( + (c) => c.cache, + 'cache', + RequestCache.noCache, + ) + .having( + (c) => c.referrer, + 'referrer', + 'https://test.com', + ) + .having( + (c) => c.referrerPolicy, + 'referrerPolicy', + RequestReferrerPolicy.noReferrer, + ) + .having( + (c) => c.redirectPolicy, + 'redirectPolicy', + RedirectPolicy.probe, + ) + .having( + (c) => c.streamRequests, + 'streamRequests', + true, + ), + ); + }); + + test('fromWebConfig creates FetchClient with default WebConfig values', () { + final webConfig = WebConfig(); + + final result = FetchClientExtension.fromWebConfig(webConfig); + + expect( + result, + isA() + .having( + (c) => c.mode, + 'mode', + RequestMode.noCors, + ) + .having( + (c) => c.credentials, + 'credentials', + RequestCredentials.sameOrigin, + ) + .having( + (c) => c.cache, + 'cache', + RequestCache.byDefault, + ) + .having( + (c) => c.referrer, + 'referrer', + '', + ) + .having( + (c) => c.referrerPolicy, + 'referrerPolicy', + RequestReferrerPolicy.strictOriginWhenCrossOrigin, + ) + .having( + (c) => c.redirectPolicy, + 'redirectPolicy', + RedirectPolicy.alwaysFollow, + ) + .having( + (c) => c.streamRequests, + 'streamRequests', + false, + ), + ); + }); + }); +} diff --git a/test/integration/eventflux_test_vm.dart b/test/client_vm_test.dart similarity index 94% rename from test/integration/eventflux_test_vm.dart rename to test/client_vm_test.dart index 3dedb81..ab20474 100644 --- a/test/integration/eventflux_test_vm.dart +++ b/test/client_vm_test.dart @@ -6,7 +6,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; -/// Run this with `flutter test test/integration/eventflux_test_vm.dart` +/// Run this with `flutter test test/eventflux_test_vm.dart` void main() { late EventFlux eventFlux; diff --git a/test/integration/eventflux_test_browser.dart b/test/integration/eventflux_test_browser.dart deleted file mode 100644 index 6a07349..0000000 --- a/test/integration/eventflux_test_browser.dart +++ /dev/null @@ -1,52 +0,0 @@ -@TestOn('browser') -import 'dart:async'; - -import 'package:eventflux/eventflux.dart'; -import 'package:fake_async/fake_async.dart'; -import 'package:fetch_client/fetch_client.dart'; -import 'package:test/test.dart'; - -/// Run this with `flutter test --platform chrome test/integration/eventflux_test_browser.dart` -void main() { - late EventFlux eventFlux; - - setUp(() { - eventFlux = EventFlux.spawn(); - }); - - for (final connectionType in [ - EventFluxConnectionType.get, - EventFluxConnectionType.post, - ]) { - test( - 'uses FetchClient in browser environment for $connectionType', - () { - final events = []; - final completer = Completer(); - const testUrl = 'https://localhost:4567'; - - fakeAsync((async) { - eventFlux.connect( - connectionType, - testUrl, - onSuccessCallback: (response) { - response?.stream?.listen( - (event) { - events.add(event.data); - if (events.length >= 3) { - completer.complete(); - } - }, - onError: (error) => completer.completeError(error), - ); - }, - onError: (error) => completer.completeError(error), - ); - }); - - expect(eventFlux.client, isA()); - }, - timeout: const Timeout(Duration(seconds: 10)), - ); - } -} diff --git a/test/models/web_config/redirect_policy_test.dart b/test/models/web_config/redirect_policy_test.dart new file mode 100644 index 0000000..74907d1 --- /dev/null +++ b/test/models/web_config/redirect_policy_test.dart @@ -0,0 +1,28 @@ +import 'package:eventflux/models/web_config/redirect_policy.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebConfigRedirectPolicy.toRedirectPolicy', () { + test('alwaysFollow converts to RedirectPolicy.alwaysFollow', () { + expect( + WebConfigRedirectPolicy.alwaysFollow.toRedirectPolicy(), + RedirectPolicy.alwaysFollow, + ); + }); + + test('probe converts to RedirectPolicy.probe', () { + expect( + WebConfigRedirectPolicy.probe.toRedirectPolicy(), + RedirectPolicy.probe, + ); + }); + + test('probeHead converts to RedirectPolicy.probeHead', () { + expect( + WebConfigRedirectPolicy.probeHead.toRedirectPolicy(), + RedirectPolicy.probeHead, + ); + }); + }); +} diff --git a/test/models/web_config/request_cache_test.dart b/test/models/web_config/request_cache_test.dart new file mode 100644 index 0000000..44006c8 --- /dev/null +++ b/test/models/web_config/request_cache_test.dart @@ -0,0 +1,49 @@ +import 'package:eventflux/models/web_config/request_cache.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebConfigRequestCache.toRequestCache', () { + test('byDefault converts to RequestCache.byDefault', () { + expect( + WebConfigRequestCache.byDefault.toRequestCache(), + RequestCache.byDefault, + ); + }); + + test('noStore converts to RequestCache.noStore', () { + expect( + WebConfigRequestCache.noStore.toRequestCache(), + RequestCache.noStore, + ); + }); + + test('reload converts to RequestCache.reload', () { + expect( + WebConfigRequestCache.reload.toRequestCache(), + RequestCache.reload, + ); + }); + + test('noCache converts to RequestCache.noCache', () { + expect( + WebConfigRequestCache.noCache.toRequestCache(), + RequestCache.noCache, + ); + }); + + test('forceCache converts to RequestCache.forceCache', () { + expect( + WebConfigRequestCache.forceCache.toRequestCache(), + RequestCache.forceCache, + ); + }); + + test('onlyIfCached converts to RequestCache.onlyIfCached', () { + expect( + WebConfigRequestCache.onlyIfCached.toRequestCache(), + RequestCache.onlyIfCached, + ); + }); + }); +} diff --git a/test/models/web_config/request_credentials_test.dart b/test/models/web_config/request_credentials_test.dart new file mode 100644 index 0000000..4592b70 --- /dev/null +++ b/test/models/web_config/request_credentials_test.dart @@ -0,0 +1,28 @@ +import 'package:eventflux/models/web_config/request_credentials.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebConfigRequestCredentials.toRequestCredentials', () { + test('sameOrigin converts to RequestCredentials.sameOrigin', () { + expect( + WebConfigRequestCredentials.sameOrigin.toRequestCredentials(), + RequestCredentials.sameOrigin, + ); + }); + + test('omit converts to RequestCredentials.omit', () { + expect( + WebConfigRequestCredentials.omit.toRequestCredentials(), + RequestCredentials.omit, + ); + }); + + test('cors converts to RequestCredentials.cors', () { + expect( + WebConfigRequestCredentials.cors.toRequestCredentials(), + RequestCredentials.cors, + ); + }); + }); +} diff --git a/test/models/web_config/request_mode_test.dart b/test/models/web_config/request_mode_test.dart new file mode 100644 index 0000000..068b952 --- /dev/null +++ b/test/models/web_config/request_mode_test.dart @@ -0,0 +1,42 @@ +import 'package:eventflux/models/web_config/request_mode.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebConfigRequestMode.toRequestMode', () { + test('sameOrigin converts to RequestMode.sameOrigin', () { + expect( + WebConfigRequestMode.sameOrigin.toRequestMode(), + RequestMode.sameOrigin, + ); + }); + + test('noCors converts to RequestMode.noCors', () { + expect( + WebConfigRequestMode.noCors.toRequestMode(), + RequestMode.noCors, + ); + }); + + test('cors converts to RequestMode.cors', () { + expect( + WebConfigRequestMode.cors.toRequestMode(), + RequestMode.cors, + ); + }); + + test('navigate converts to RequestMode.navigate', () { + expect( + WebConfigRequestMode.navigate.toRequestMode(), + RequestMode.navigate, + ); + }); + + test('webSocket converts to RequestMode.webSocket', () { + expect( + WebConfigRequestMode.webSocket.toRequestMode(), + RequestMode.webSocket, + ); + }); + }); +} diff --git a/test/models/web_config/request_referrer_policy_test.dart b/test/models/web_config/request_referrer_policy_test.dart new file mode 100644 index 0000000..39364c6 --- /dev/null +++ b/test/models/web_config/request_referrer_policy_test.dart @@ -0,0 +1,66 @@ +import 'package:eventflux/models/web_config/request_referrer_policy.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebConfigRequestReferrerPolicy.toRequestReferrerPolicy', () { + test('strictOriginWhenCrossOrigin converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.strictOriginWhenCrossOrigin + .toRequestReferrerPolicy(), + RequestReferrerPolicy.strictOriginWhenCrossOrigin, + ); + }); + + test('noReferrer converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.noReferrer.toRequestReferrerPolicy(), + RequestReferrerPolicy.noReferrer, + ); + }); + + test('noReferrerWhenDowngrade converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.noReferrerWhenDowngrade + .toRequestReferrerPolicy(), + RequestReferrerPolicy.noReferrerWhenDowngrade, + ); + }); + + test('sameOrigin converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.sameOrigin.toRequestReferrerPolicy(), + RequestReferrerPolicy.sameOrigin, + ); + }); + + test('origin converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.origin.toRequestReferrerPolicy(), + RequestReferrerPolicy.origin, + ); + }); + + test('strictOrigin converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.strictOrigin.toRequestReferrerPolicy(), + RequestReferrerPolicy.strictOrigin, + ); + }); + + test('originWhenCrossOrigin converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.originWhenCrossOrigin + .toRequestReferrerPolicy(), + RequestReferrerPolicy.originWhenCrossOrigin, + ); + }); + + test('unsafeUrl converts correctly', () { + expect( + WebConfigRequestReferrerPolicy.unsafeUrl.toRequestReferrerPolicy(), + RequestReferrerPolicy.unsafeUrl, + ); + }); + }); +} diff --git a/test/models/web_config/web_config_test.dart b/test/models/web_config/web_config_test.dart new file mode 100644 index 0000000..7de2404 --- /dev/null +++ b/test/models/web_config/web_config_test.dart @@ -0,0 +1,75 @@ +import 'package:eventflux/models/web_config/redirect_policy.dart'; +import 'package:eventflux/models/web_config/request_cache.dart'; +import 'package:eventflux/models/web_config/request_credentials.dart'; +import 'package:eventflux/models/web_config/request_mode.dart'; +import 'package:eventflux/models/web_config/request_referrer_policy.dart'; +import 'package:eventflux/models/web_config/web_config.dart'; +import 'package:test/test.dart'; + +void main() { + group('WebConfig', () { + test('constructor has correct default values', () { + expect( + WebConfig(), + WebConfig( + mode: WebConfigRequestMode.noCors, + credentials: WebConfigRequestCredentials.sameOrigin, + cache: WebConfigRequestCache.byDefault, + referrer: '', + referrerPolicy: + WebConfigRequestReferrerPolicy.strictOriginWhenCrossOrigin, + redirectPolicy: WebConfigRedirectPolicy.alwaysFollow, + streamRequests: false, + ), + ); + }); + + group('copyWith', () { + test('returns a copy of itself', () { + final webConfig = WebConfig(); + final copy = webConfig.copyWith(); + expect(copy, webConfig); + }); + + test('returns a copy with the given fields replaced', () { + final webConfig = WebConfig(); + final copy = webConfig.copyWith( + mode: WebConfigRequestMode.sameOrigin, + credentials: WebConfigRequestCredentials.cors, + ); + expect( + copy, + WebConfig( + mode: WebConfigRequestMode.sameOrigin, + credentials: WebConfigRequestCredentials.cors, + ), + ); + }); + + test('returns a copy with all fields replaced', () { + final webConfig = WebConfig(); + final copy = webConfig.copyWith( + mode: WebConfigRequestMode.sameOrigin, + credentials: WebConfigRequestCredentials.cors, + cache: WebConfigRequestCache.noCache, + referrer: 'https://test.com', + referrerPolicy: WebConfigRequestReferrerPolicy.noReferrer, + redirectPolicy: WebConfigRedirectPolicy.probe, + streamRequests: true, + ); + expect( + copy, + WebConfig( + mode: WebConfigRequestMode.sameOrigin, + credentials: WebConfigRequestCredentials.cors, + cache: WebConfigRequestCache.noCache, + referrer: 'https://test.com', + referrerPolicy: WebConfigRequestReferrerPolicy.noReferrer, + redirectPolicy: WebConfigRedirectPolicy.probe, + streamRequests: true, + ), + ); + }); + }); + }); +}