diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index d1582896..51fb748d 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -1,9 +1,6 @@ name: 📊 Code Coverage -on: - push: - branches: [master, develop] - pull_request: +on: [push, pull_request] env: PUB_ENVIRONMENT: bot.github diff --git a/.github/workflows/code-integration.yml b/.github/workflows/code-integration.yml index 12b0a3d4..28e34245 100644 --- a/.github/workflows/code-integration.yml +++ b/.github/workflows/code-integration.yml @@ -17,6 +17,10 @@ jobs: integration_tests_android: name: 🤖 Android Tests runs-on: ubuntu-latest + strategy: + matrix: + api-level: [ 23, 35 ] + target: [ default ] timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -35,9 +39,36 @@ jobs: echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - uses: reactivecircus/android-emulator-runner@v2 + - name: Gradle cache + uses: gradle/actions/setup-gradle@v4 + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache with: - api-level: 29 + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + - name: Run integration test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true script: flutter test integration_test working-directory: flutter_secure_storage/example @@ -59,5 +90,15 @@ jobs: os: iOS os_version: ">=18.1" model: "iPhone 15" + - run: flutter pub get + working-directory: flutter_secure_storage/example + - name: Pod Install + working-directory: flutter_secure_storage/example + run: | + cd ios + pod install + cd .. + - run: flutter build ios --simulator --target=integration_test/app_test.dart + working-directory: flutter_secure_storage/example - run: flutter test integration_test - working-directory: flutter_secure_storage/example \ No newline at end of file + working-directory: flutter_secure_storage/example diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 4cb11d43..c0d80abe 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,9 +1,6 @@ name: 📝 Code Quality -on: - push: - branches: [master, develop] - pull_request: +on: [push, pull_request] defaults: run: diff --git a/flutter_secure_storage/example/integration_test/app_test.dart b/flutter_secure_storage/example/integration_test/app_test.dart index a5ae8c72..bc98ff68 100644 --- a/flutter_secure_storage/example/integration_test/app_test.dart +++ b/flutter_secure_storage/example/integration_test/app_test.dart @@ -78,9 +78,11 @@ void main() { }); } +Duration duration = const Duration(milliseconds: 300); + Future _setupHomePage(WidgetTester tester) async { await tester.pumpWidget(const MaterialApp(home: HomePage())); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); final pageObject = HomePageObject(tester); await pageObject.deleteAll(); return pageObject; @@ -112,7 +114,7 @@ class HomePageObject { final textField = find.byKey(const Key('value_field')); expect(textField, findsOneWidget, reason: 'Value text field not found'); await tester.enterText(textField, newValue); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); await _tap(find.byKey(const Key('save'))); } @@ -142,11 +144,11 @@ class HomePageObject { final textField = find.byKey(const Key('key_field')); expect(textField, findsOneWidget); await tester.enterText(textField, keyWidget.data!); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); // Confirm the action await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); // Verify the SnackBar message final expectedText = 'Contains Key: $expectedResult'; @@ -168,11 +170,11 @@ class HomePageObject { final textField = find.byKey(const Key('key_field')); expect(textField, findsOneWidget); await tester.enterText(textField, keyWidget.data!); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); // Confirm the action await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); // Verify the SnackBar message expect(find.text('value: $expectedValue'), findsOneWidget); @@ -213,6 +215,6 @@ class HomePageObject { reason: 'Widget not found for tapping: $finder', ); await tester.tap(finder); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(duration); } } diff --git a/flutter_secure_storage/example/test/widget_test.dart b/flutter_secure_storage/example/test/widget_test.dart deleted file mode 100644 index ceb116d2..00000000 --- a/flutter_secure_storage/example/test/widget_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -// This is a basic Flutter widget test. -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -void main() {} diff --git a/flutter_secure_storage/lib/flutter_secure_storage.dart b/flutter_secure_storage/lib/flutter_secure_storage.dart index 34379cab..92291cd5 100644 --- a/flutter_secure_storage/lib/flutter_secure_storage.dart +++ b/flutter_secure_storage/lib/flutter_secure_storage.dart @@ -1,5 +1,7 @@ library; +import 'dart:collection'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/test/test_flutter_secure_storage_platform.dart'; @@ -74,6 +76,11 @@ class FlutterSecureStorage { FlutterSecureStoragePlatform get _platform => FlutterSecureStoragePlatform.instance; + /// Returns all listeners for testing purposes + @visibleForTesting + Map>> get getListeners => + UnmodifiableMapView(_listeners); + /// Register [listener] for [key] with the value injected for the listener. /// The [listener] will still be called when you delete the [key] with the /// injected value as null. This listener will be added to the list of diff --git a/flutter_secure_storage/test/flutter_secure_storage_test.dart b/flutter_secure_storage/test/flutter_secure_storage_test.dart index 3357ce7f..b2488a1b 100644 --- a/flutter_secure_storage/test/flutter_secure_storage_test.dart +++ b/flutter_secure_storage/test/flutter_secure_storage_test.dart @@ -1,25 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_secure_storage/test/test_flutter_secure_storage_platform.dart'; import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -// ✅ Correct Mock Class Implementation class MockFlutterSecureStoragePlatform extends Mock with MockPlatformInterfaceMixin implements FlutterSecureStoragePlatform {} +class ImplementsFlutterSecureStoragePlatform extends Mock + implements FlutterSecureStoragePlatform {} + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FlutterSecureStorage storage; late MockFlutterSecureStoragePlatform mockPlatform; + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + final methodStorage = MethodChannelFlutterSecureStorage(); + final log = []; + + Future? handler(MethodCall methodCall) async { + log.add(methodCall); + if (methodCall.method == 'containsKey') { + return true; + } else if (methodCall.method == 'isProtectedDataAvailable') { + return true; + } + return null; + } + setUp(() { mockPlatform = MockFlutterSecureStoragePlatform(); + FlutterSecureStoragePlatform.instance = mockPlatform; storage = const FlutterSecureStorage(); + + // Ensure method channel mock is set up for the tests + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, handler); + + log.clear(); // Clear logs before each test + }); + + tearDown(() { + log.clear(); // Clear logs after each test + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); // Remove the mock handler }); - group('FlutterSecureStorage Tests', () { + group('Method Channel Interaction Tests for FlutterSecureStorage', () { + test('read', () async { + const key = 'test_key'; + const options = {}; + await methodStorage.read(key: key, options: options); + + expect( + log, + [ + isMethodCall( + 'read', + arguments: { + 'key': key, + 'options': options, + }, + ), + ], + ); + }); + + test('write', () async { + const key = 'test_key'; + const options = {}; + await methodStorage.write(key: key, value: 'test', options: options); + + expect( + log, + [ + isMethodCall( + 'write', + arguments: { + 'key': key, + 'value': 'test', + 'options': options, + }, + ), + ], + ); + }); + + test('containsKey', () async { + const key = 'test_key'; + const options = {}; + await methodStorage.write(key: key, value: 'test', options: options); + + final result = + await methodStorage.containsKey(key: key, options: options); + + expect(result, true); + }); + + test('delete', () async { + const key = 'test_key'; + const options = {}; + await methodStorage.write(key: key, value: 'test', options: options); + await methodStorage.delete(key: key, options: options); + + expect( + log, + [ + isMethodCall( + 'write', + arguments: { + 'key': key, + 'value': 'test', + 'options': options, + }, + ), + isMethodCall( + 'delete', + arguments: { + 'key': key, + 'options': options, + }, + ), + ], + ); + }); + + test('deleteAll', () async { + const options = {}; + await methodStorage.deleteAll(options: options); + + expect( + log, + [ + isMethodCall( + 'deleteAll', + arguments: { + 'options': options, + }, + ), + ], + ); + }); + }); + + group('Platform-Specific Interface Tests', () { + test('Cannot be implemented with `implements`', () { + expect( + () { + FlutterSecureStoragePlatform.instance = + ImplementsFlutterSecureStoragePlatform(); + }, + throwsA(isInstanceOf()), + ); + }); + + test('Can be mocked with `implements`', () { + final mock = MockFlutterSecureStoragePlatform(); + FlutterSecureStoragePlatform.instance = mock; + }); + + test('Can be extended', () { + FlutterSecureStoragePlatform.instance = + TestFlutterSecureStoragePlatform({}); + }); + }); + + group('FlutterSecureStorage Methods Invocation Tests', () { const testKey = 'testKey'; const testValue = 'testValue'; @@ -80,6 +233,38 @@ void main() { ).called(1); }); + test('deleteAll should call platform delete all method', () async { + when( + () => mockPlatform.deleteAll( + options: any(named: 'options'), + ), + ).thenAnswer((_) async {}); + + await storage.deleteAll(); + + verify( + () => mockPlatform.deleteAll( + options: any(named: 'options'), + ), + ).called(1); + }); + + test('readAll should call platform read all method', () async { + when( + () => mockPlatform.readAll( + options: any(named: 'options'), + ), + ).thenAnswer((_) async => {testKey: testValue}); + + await storage.readAll(); + + verify( + () => mockPlatform.readAll( + options: any(named: 'options'), + ), + ).called(1); + }); + test('containsKey should return true if key exists', () async { when( () => mockPlatform.containsKey( @@ -118,7 +303,63 @@ void main() { }); }); - group('AndroidOptions Tests', () { + group('Test FlutterSecureStorage Methods', () { + late TestFlutterSecureStoragePlatform storagePlatform; + final initialData = {'key1': 'value1', 'key2': 'value2'}; + + setUp(() { + storagePlatform = TestFlutterSecureStoragePlatform(Map.from(initialData)); + }); + + test('reads a value', () async { + expect(await storagePlatform.read(key: 'key1', options: {}), 'value1'); + }); + + test('returns null for non-existent key', () async { + expect(await storagePlatform.read(key: 'key3', options: {}), isNull); + }); + + test('writes a value', () async { + await storagePlatform.write(key: 'key3', value: 'value3', options: {}); + expect(storagePlatform.data['key3'], 'value3'); + }); + + test('containsKey returns true for existing key', () async { + expect( + await storagePlatform.containsKey(key: 'key1', options: {}), + isTrue, + ); + }); + + test('containsKey returns false for non-existing key', () async { + expect( + await storagePlatform.containsKey(key: 'key3', options: {}), + isFalse, + ); + }); + + test('deletes a value', () async { + await storagePlatform.delete(key: 'key1', options: {}); + expect(storagePlatform.data.containsKey('key1'), isFalse); + }); + + test('deleteAll clears all data', () async { + await storagePlatform.deleteAll(options: {}); + expect(storagePlatform.data.isEmpty, isTrue); + }); + + test('readAll returns all key-value pairs', () async { + final allData = await storagePlatform.readAll(options: {}); + expect(allData, equals(initialData)); + }); + + test('modifying data does not affect initial data map', () async { + await storagePlatform.write(key: 'key1', value: 'newvalue1', options: {}); + expect(initialData['key1'], 'value1'); + }); + }); + + group('AndroidOptions Configuration Tests', () { test('Default AndroidOptions should have correct default values', () { const options = AndroidOptions.defaultOptions; @@ -201,7 +442,7 @@ void main() { }); }); - group('WebOptions Tests', () { + group('WebOptions Configuration Tests', () { test('Default WebOptions should have correct default values', () { const options = WebOptions.defaultOptions; @@ -261,7 +502,7 @@ void main() { }); }); - group('WindowsOptions Tests', () { + group('WindowsOptions Configuration Tests', () { test('Default WindowsOptions should have correct default values', () { const options = WindowsOptions.defaultOptions; @@ -308,7 +549,7 @@ void main() { }); }); - group('IOSOptions Tests', () { + group('iOSOptions Configuration Tests', () { test('Default IOSOptions should have correct default values', () { const options = IOSOptions.defaultOptions; @@ -368,8 +609,8 @@ void main() { }); }); - group('MacOsOptions Tests', () { - test('Default MacOsOptions should have correct default values', () { + group('macOSOptions Configuration Tests', () { + test('Default macOSOptions should have correct default values', () { // Ignore for test // ignore: use_named_constants const options = MacOsOptions(); @@ -382,7 +623,7 @@ void main() { }); }); - test('MacOsOptions with custom values', () { + test('macOSOptions with custom values', () { const options = MacOsOptions( accountName: 'macAccount', groupId: 'group.mac.example', @@ -400,7 +641,7 @@ void main() { }); }); - test('MacOsOptions defaultOptions matches default constructor', () { + test('macOSOptions defaultOptions matches default constructor', () { const defaultOptions = MacOsOptions.defaultOptions; // Ignore for test // ignore: use_named_constants @@ -409,4 +650,53 @@ void main() { expect(defaultOptions.toMap(), constructorOptions.toMap()); }); }); + + group('Listener Management Tests', () { + late ValueChanged listener1; + late ValueChanged listener2; + + setUp(() { + storage.unregisterAllListeners(); + listener1 = (value) => debugPrint('Listener 1: $value'); + listener2 = (value) => debugPrint('Listener 2: $value'); + }); + + test('Register listener adds correctly', () { + storage.registerListener(key: 'key1', listener: listener1); + expect(storage.getListeners['key1']?.contains(listener1), isTrue); + }); + + test('Register multiple listeners on same key', () { + storage + ..registerListener(key: 'key1', listener: listener1) + ..registerListener(key: 'key1', listener: listener2); + expect(storage.getListeners['key1']?.length, 2); + expect(storage.getListeners['key1'], containsAll([listener1, listener2])); + }); + + test('Unregister listener removes specific listener', () { + storage + ..registerListener(key: 'key1', listener: listener1) + ..registerListener(key: 'key1', listener: listener2) + ..unregisterListener(key: 'key1', listener: listener1); + expect(storage.getListeners['key1']?.contains(listener1), isFalse); + expect(storage.getListeners['key1']?.contains(listener2), isTrue); + }); + + test('Unregister all listeners for a key', () { + storage + ..registerListener(key: 'key1', listener: listener1) + ..registerListener(key: 'key1', listener: listener2) + ..unregisterAllListenersForKey(key: 'key1'); + expect(storage.getListeners.containsKey('key1'), isFalse); + }); + + test('Unregister all listeners for all keys', () { + storage + ..registerListener(key: 'key1', listener: listener1) + ..registerListener(key: 'key2', listener: listener2) + ..unregisterAllListeners(); + expect(storage.getListeners.isEmpty, isTrue); + }); + }); }