From 374feb432eba4cd50b37f2b6458f3bb54c5c32b7 Mon Sep 17 00:00:00 2001 From: koukibadr Date: Wed, 8 Jul 2026 09:28:53 +0300 Subject: [PATCH] test: setup unit test for flutter_skin main class and CI check on PRs --- .../workflows/pr_verification_pipeline.yml | 57 +++++++++++++--- lib/constants/fskin_constants.dart | 1 + lib/flutter_skin.dart | 30 ++++++-- lib/remote/fskin_remote_config.dart | 4 +- lib/services/skin_service.dart | 2 +- pubspec.yaml | 1 + test/flutter_skin/flutter_skin_init_test.dart | 68 +++++++++++++++++++ .../initialization_scenario_test.dart | 34 ++++++++++ test/flutter_skin_test.dart | 5 -- test/mocks/skin_model_mocks.dart | 22 ++++++ 10 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 test/flutter_skin/flutter_skin_init_test.dart create mode 100644 test/flutter_skin/initialization_scenario_test.dart delete mode 100644 test/flutter_skin_test.dart create mode 100644 test/mocks/skin_model_mocks.dart diff --git a/.github/workflows/pr_verification_pipeline.yml b/.github/workflows/pr_verification_pipeline.yml index 5830374..8d51d2d 100644 --- a/.github/workflows/pr_verification_pipeline.yml +++ b/.github/workflows/pr_verification_pipeline.yml @@ -2,20 +2,23 @@ on: pull_request: branches: [ "main" ] jobs: - - analyze-code: + dart_format_and_analyze: + name: dart format and analyze runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install Flutter - uses: subosito/flutter-action@v2 + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 with: channel: stable - - name: Install dependencies - run: flutter pub get - + + - name: Run pub get + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + - name: Running Flutter analysis on package code - run: flutter analyze --no-fatal-infos --no-fatal-warnings --write=analysis_report.txt + run: flutter analyze --no-fatal-warnings --write=analysis_report.txt - name: Artifact analysis report uses: actions/upload-artifact@v4 @@ -24,4 +27,38 @@ jobs: name: analysis-report path: | analysis_report.txt - retention-days: 15 \ No newline at end of file + retention-days: 15 + + unit-test: + needs: dart_format_and_analyze + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Run unit tests + run: flutter test + + dry-run-upload: + needs: unit-test + name: Dry run upload + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Check Publish Warnings + run: dart pub publish --dry-run \ No newline at end of file diff --git a/lib/constants/fskin_constants.dart b/lib/constants/fskin_constants.dart index e7da73a..ed66ddb 100644 --- a/lib/constants/fskin_constants.dart +++ b/lib/constants/fskin_constants.dart @@ -1,3 +1,4 @@ class FskinConstants { static const String baseUrl = 'fskinbackend-production.up.railway.app'; + static const String keyRegex = r'^fsk_[a-f0-9]{64}$'; } diff --git a/lib/flutter_skin.dart b/lib/flutter_skin.dart index 2a6ead2..3e73c7c 100644 --- a/lib/flutter_skin.dart +++ b/lib/flutter_skin.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' show ThemeData, ColorScheme; import 'package:flutter/widgets.dart'; +import 'package:flutter_skin/constants/fskin_constants.dart'; import 'package:flutter_skin/models/project_config.dart'; import 'package:flutter_skin/remote/fskin_remote_config.dart'; import 'package:flutter_skin/services/fskin_logger.dart'; @@ -10,7 +11,8 @@ class FlutterSkin with WidgetsBindingObserver { late String apiKey; static final FskinSubscriber _sse = FskinSubscriber(); - static Stream get onSkinChanged => FskinRemoteConfig.onSkinChanged; + static FskinRemoteConfig remoteConfig = FskinRemoteConfig.singleton; + static Stream get onSkinChanged => remoteConfig.onSkinChanged; static final FskinLogger _logger = FskinLogger(); // Private constructor @@ -20,7 +22,11 @@ class FlutterSkin with WidgetsBindingObserver { if (apiKey.trim().isEmpty) { _logger.logError('apiKey must not be empty'); throw ArgumentError.value(apiKey, 'apiKey', 'apiKey must not be empty'); + } else if (RegExp(FskinConstants.keyRegex).hasMatch(apiKey) == false) { + _logger.logError('apiKey is not valid'); + throw ArgumentError.value(apiKey, 'apiKey', 'apiKey is not valid'); } + if (_instance == null) { _instance = FlutterSkin._(); WidgetsBinding.instance.addObserver(_instance!); @@ -48,7 +54,7 @@ class FlutterSkin with WidgetsBindingObserver { ); _sse.listen( apiKey: _instance!.apiKey, - onSkinUpdated: FskinRemoteConfig.singleton.fetchConfig, + onSkinUpdated: remoteConfig.fetchConfig, ); } @@ -58,7 +64,7 @@ class FlutterSkin with WidgetsBindingObserver { if (state == AppLifecycleState.resumed) { try { _logger.logMessage('Fetching latest config and restarting stream.'); - await FskinRemoteConfig.singleton.fetchConfig(); + await remoteConfig.fetchConfig(); } finally { _startStream(); } @@ -73,7 +79,7 @@ class FlutterSkin with WidgetsBindingObserver { /// Query current active theme from remote config and return as ThemeData. /// When there's no active theme, the result is null or fallbackTheme if provided. static ThemeData? toThemeData({ThemeData? fallbackTheme}) { - ProjectConfig? config = FskinRemoteConfig.projectConfig; + ProjectConfig? config = remoteConfig.projectConfig; ColorScheme? colors = config?.skin?.colors; ThemeData remoteTheme = ThemeData(colorScheme: colors); if (colors == null) { @@ -91,6 +97,22 @@ class FlutterSkin with WidgetsBindingObserver { /// Query current active theme from remote config and return as ThemeData. /// When there's no active theme, the result is null. static ThemeData? get theme { + if (_instance == null) { + throw Exception( + 'FlutterSkin must be initialized with FlutterSkin.init()', + ); + } return toThemeData(); } + + @visibleForTesting + static void resetInstance() { + _instance = null; + FlutterSkin.remoteConfig = FskinRemoteConfig.singleton; + } + + @visibleForTesting + void setRemoteConfig(FskinRemoteConfig remoteConfig) { + FlutterSkin.remoteConfig = remoteConfig; + } } diff --git a/lib/remote/fskin_remote_config.dart b/lib/remote/fskin_remote_config.dart index d4077d9..a5de829 100644 --- a/lib/remote/fskin_remote_config.dart +++ b/lib/remote/fskin_remote_config.dart @@ -15,9 +15,9 @@ class FskinRemoteConfig { late String apiKey; ProjectConfig? _cachedConfig; - static Stream get onSkinChanged => _skinController.stream; + Stream get onSkinChanged => _skinController.stream; - static ProjectConfig? get projectConfig { + ProjectConfig? get projectConfig { if (_instance == null) { throw Exception( 'FskinRemoteConfig must be initialized with FskinRemoteConfig.init()', diff --git a/lib/services/skin_service.dart b/lib/services/skin_service.dart index f6b8f88..8636fa6 100644 --- a/lib/services/skin_service.dart +++ b/lib/services/skin_service.dart @@ -28,7 +28,7 @@ class SkinService { body: jsonEncode({'apiKey': apiKey}), ) .timeout(const Duration(seconds: 5)); - + _logger.logMessage( 'Received response with status code: ${response.statusCode}', ); diff --git a/pubspec.yaml b/pubspec.yaml index c0a7ceb..bc55693 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + mockito: ^5.5.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/flutter_skin/flutter_skin_init_test.dart b/test/flutter_skin/flutter_skin_init_test.dart new file mode 100644 index 0000000..88670fd --- /dev/null +++ b/test/flutter_skin/flutter_skin_init_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_skin/flutter_skin.dart'; +import 'package:flutter_skin/models/project_config.dart'; +import 'package:flutter_skin/remote/fskin_remote_config.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../mocks/skin_model_mocks.dart'; + +class MockFskinRemoteConfig extends Mock implements FskinRemoteConfig {} + +class MockProjectConfig extends Mock implements ProjectConfig {} + +void main() async { + group('FlutterSkin Initialization Tests', () { + final apiKey = + 'fsk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await FlutterSkin.init(apiKey: apiKey); + }); + + test('fetches and applies skin on successful response', () async { + // Testing with fake api key will result in null theme. + await FlutterSkin.init(apiKey: apiKey); + + final skin = FlutterSkin.theme; + final instance = FlutterSkin.singleton; + + expect(instance.apiKey, apiKey); + expect(skin, null); + }); + + test('applies skin from remote config', () async { + final instance = FlutterSkin.singleton; + + instance.setRemoteConfig(MockFskinRemoteConfig()); + when( + FlutterSkin.remoteConfig.projectConfig, + ).thenReturn(ProjectConfig(skin: skinModelMock)); + + final skin = FlutterSkin.theme; + expect(instance.apiKey, apiKey); + expect(skin, isA()); + expect(skin?.colorScheme.primary, skinModelMock.colors?.primary); + }); + + test('project config returns a nullable skin', () async { + final instance = FlutterSkin.singleton; + + instance.setRemoteConfig(MockFskinRemoteConfig()); + when( + FlutterSkin.remoteConfig.projectConfig, + ).thenReturn(MockProjectConfig()); + + var skin = FlutterSkin.theme; + expect(instance.apiKey, apiKey); + expect(skin, isA()); + + skin = FlutterSkin.toThemeData( + fallbackTheme: ThemeData(primaryColor: Colors.red), + ); + expect(skin, isA()); + expect(skin?.brightness, Brightness.light); + }); + }); +} diff --git a/test/flutter_skin/initialization_scenario_test.dart b/test/flutter_skin/initialization_scenario_test.dart new file mode 100644 index 0000000..91da77c --- /dev/null +++ b/test/flutter_skin/initialization_scenario_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_skin/flutter_skin.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Initialization test across different scenarios', () { + test( + 'should throw an exception if init is called with an empty apiKey', + () async { + expect( + () => FlutterSkin.init(apiKey: ''), + throwsA(isA()), + ); + }, + ); + + test( + 'should throw an exception if init is called with an invalid apiKey', + () async { + expect( + () => FlutterSkin.init(apiKey: 'my_api_key'), + throwsA(isA()), + ); + }, + ); + + test('should throw an exception if singleton is accessed before init', () { + expect(() => FlutterSkin.singleton, throwsA(isA())); + }); + + test('should throw an exception if theme is accessed before init', () { + expect(() => FlutterSkin.theme, throwsA(isA())); + }); + }); +} diff --git a/test/flutter_skin_test.dart b/test/flutter_skin_test.dart deleted file mode 100644 index 0da434d..0000000 --- a/test/flutter_skin_test.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('adds one to input values', () {}); -} diff --git a/test/mocks/skin_model_mocks.dart b/test/mocks/skin_model_mocks.dart new file mode 100644 index 0000000..f1df147 --- /dev/null +++ b/test/mocks/skin_model_mocks.dart @@ -0,0 +1,22 @@ +// ignore_for_file: deprecated_member_use + +import 'package:flutter/material.dart'; +import 'package:flutter_skin/models/skin_model.dart'; + +var skinModelMock = SkinModel( + colors: const ColorScheme.light( + primary: Color(0xFF6200EE), + secondary: Color(0xFF03DAC6), + background: Color(0xFFFFFFFF), + surface: Color(0xFFFFFFFF), + onPrimary: Color(0xFFFFFFFF), + onSecondary: Color(0xFF000000), + onBackground: Color(0xFF000000), + onSurface: Color(0xFF000000), + ), + id: 'mock_id', + projectId: 'mock_project_id', + isActive: true, + version: 1, + createdAt: DateTime.now(), +);