diff --git a/.gitignore b/.gitignore index a45e2a8..1f54de6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ backend/**/target finlog_app/**/.idea -finlog_app/**/.dart_tool \ No newline at end of file +finlog_app/**/.dart_tool + +finlog_app/**/pubspec.lock \ No newline at end of file diff --git a/backend/.gitlab-ci.yml b/backend/.gitlab-ci.yml index a126f41..c88a6c4 100644 --- a/backend/.gitlab-ci.yml +++ b/backend/.gitlab-ci.yml @@ -7,5 +7,7 @@ build_backend: artifacts: paths: - backend/common/target/ - - backend/server/target + - backend/discovery/target/ + - backend/gateway/target/ + - backend/server/target/ expire_in: 1 hour diff --git a/backend/discovery/pom.xml b/backend/discovery/pom.xml new file mode 100644 index 0000000..541418c --- /dev/null +++ b/backend/discovery/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + + dev.rheinsw.finlog.backend + backend + ${revision} + + + discovery + \ No newline at end of file diff --git a/backend/gateway/pom.xml b/backend/gateway/pom.xml new file mode 100644 index 0000000..db4c83a --- /dev/null +++ b/backend/gateway/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + dev.rheinsw.finlog.backend + backend + ${revision} + + + gateway + + + 21 + 21 + UTF-8 + + + + + + dev.rheinsw.finlog.backend + common + ${revision} + compile + + + + \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index d0f671d..056d12a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -12,6 +12,8 @@ common server + gateway + discovery diff --git a/finlog_app/app/lib/main.dart b/finlog_app/app/lib/main.dart index 7b7f5b6..8f08deb 100644 --- a/finlog_app/app/lib/main.dart +++ b/finlog_app/app/lib/main.dart @@ -1,6 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:fluttery/fluttery.dart'; +import 'package:fluttery/logger.dart'; +import 'package:fluttery/worker.dart'; + +Future main() async { + // Ensures that the Flutter engine and widget binding + // are initialized before using async services or plugins + WidgetsFlutterBinding.ensureInitialized(); + + // any services + App.registerDefaultServices(); + + final logger = App.service(); + logger.debug("[MAIN] Registered all default services"); -void main() { runApp(const MyApp()); } @@ -10,24 +23,11 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { + App.service().info("test"); + return MaterialApp( title: 'Flutter Demo', theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), ), home: const MyHomePage(title: 'Flutter Demo Home Page'), @@ -38,15 +38,6 @@ class MyApp extends StatelessWidget { class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - final String title; @override @@ -57,51 +48,27 @@ class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() { + App.service().spawn("worker-$_counter", () async { + App.service().info("test"); + + await Future.delayed(const Duration(seconds: 10)); + + App.service().info("end worker"); + }); setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. _counter++; }); } @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. return Scaffold( appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. title: Text(widget.title), ), body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), @@ -109,6 +76,11 @@ class _MyHomePageState extends State { '$_counter', style: Theme.of(context).textTheme.headlineMedium, ), + TextButton( + onPressed: () { + }, + child: Text("Print workers"), + ), ], ), ), diff --git a/finlog_app/app/pubspec.yaml b/finlog_app/app/pubspec.yaml index 09d2538..4a153ec 100644 --- a/finlog_app/app/pubspec.yaml +++ b/finlog_app/app/pubspec.yaml @@ -31,6 +31,8 @@ environment: dependencies: flutter: sdk: flutter + fluttery: + path: ../fluttery # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/finlog_app/app/test/widget_test.dart b/finlog_app/app/test/widget_test.dart index 4e2a713..6a6887d 100644 --- a/finlog_app/app/test/widget_test.dart +++ b/finlog_app/app/test/widget_test.dart @@ -1,30 +1,7 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. 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. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:app/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + test('description', () { + expect(1, 1); }); } diff --git a/finlog_app/fluttery/lib/environment.dart b/finlog_app/fluttery/lib/environment.dart new file mode 100644 index 0000000..1e9e91b --- /dev/null +++ b/finlog_app/fluttery/lib/environment.dart @@ -0,0 +1,27 @@ +import 'package:fluttery/fluttery.dart'; + +/// Abstract Environment contract +abstract class Environment extends Service { + /// Platform checks + bool get isAndroid; + + bool get isIOS; + + /// Build mode + bool get isDebug; + + bool get isRelease; + + bool get isProfile; + + /// App info + Future loadPackageInfo(); + + String get appName; + + String get packageName; + + String get version; + + String get buildNumber; +} diff --git a/finlog_app/fluttery/lib/fluttery.dart b/finlog_app/fluttery/lib/fluttery.dart index 298576d..02e1dce 100644 --- a/finlog_app/fluttery/lib/fluttery.dart +++ b/finlog_app/fluttery/lib/fluttery.dart @@ -1,5 +1,73 @@ -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; +library; + +import 'package:fluttery/environment.dart'; +import 'package:fluttery/logger.dart'; +import 'package:fluttery/preferences.dart'; +import 'package:fluttery/secure_storage.dart'; +import 'package:fluttery/src/logger/logger_impl.dart'; +import 'package:fluttery/src/preferences/preferences_impl.dart'; +import 'package:fluttery/src/storage/secure/secure_storage_impl.dart'; +import 'package:fluttery/src/system/environment/environment_impl.dart'; +import 'package:fluttery/src/system/worker/worker_impl.dart'; +import 'package:fluttery/worker.dart'; +import 'package:kiwi/kiwi.dart'; + +/// A class to manage services. +class App { + static final _AppService _appService = _AppService(); + + /// Registers a service with a factory function to instantiate the implementation. + /// + /// This ensures that the implementation is created when the service is requested. + /// + /// `implFactory` - A factory method to create the service implementation. + static void registerService(T Function() implFactory) { + _appService.registerSingleton(implFactory); + } + + /// Retrieves the registered service. + /// + /// Returns an instance of the registered service. + static T service() { + return _appService.resolve(); + } + + /// Registers the default services required by the application. + static void registerDefaultServices() { + registerService(() => LoggerImpl()); + registerService(() => PreferencesImpl()); + registerService(() => EnvironmentImpl()); + registerService(() => SecureStorageImpl()); + registerService(() => WorkerImpl()); + } +} + +/// Abstract class to represent a service. +abstract class Service {} + +/// Internal class to manage the registration and resolution of services. +class _AppService { + static _AppService? _singleton; + + static final KiwiContainer _kiwi = KiwiContainer(); + + /// Factory constructor to ensure singleton instance of _AppService. + factory _AppService() => _singleton ??= _AppService._(); + + /// Private constructor. + _AppService._(); + + /// Registers a singleton service with a factory function to create the instance. + /// + /// `serviceFactory` - A factory method to create the service implementation. + void registerSingleton(T Function() serviceFactory) { + _kiwi.registerSingleton((c) => serviceFactory()); + } + + /// Resolves and retrieves the registered service. + /// + /// Returns an instance of the registered service. + T resolve() { + return _kiwi.resolve(); + } } diff --git a/finlog_app/fluttery/lib/logger.dart b/finlog_app/fluttery/lib/logger.dart new file mode 100644 index 0000000..ee1f438 --- /dev/null +++ b/finlog_app/fluttery/lib/logger.dart @@ -0,0 +1,33 @@ +import 'package:fluttery/fluttery.dart'; +import 'package:logging/logging.dart' as lib; + +/// Abstract class for logging service. +/// Provides methods for different log levels and configuration. +abstract class Logger extends Service { + /// Logs an informational message. + /// + /// [message] is the information to log. + void info(String message); + + /// Logs a warning message. + /// + /// [message] is the warning to log. + void warning(String message); + + /// Logs an error message with optional error and stack trace. + /// + /// [message] is the error message to log. + /// [error] is the optional error object associated with this log entry. + /// [stackTrace] is the optional stack trace associated with this log entry. + void error(String message, [Object? error, StackTrace? stackTrace]); + + /// Logs a debug message. + /// + /// [message] is the debug message to log. + void debug(String message); + + /// Sets the log level for the logger. + /// + /// [level] is the new log level to set. + void setLogLevel(lib.Level level); +} diff --git a/finlog_app/fluttery/lib/preferences.dart b/finlog_app/fluttery/lib/preferences.dart new file mode 100644 index 0000000..f0b7585 --- /dev/null +++ b/finlog_app/fluttery/lib/preferences.dart @@ -0,0 +1,40 @@ +import 'package:fluttery/fluttery.dart'; + +/// providing methods for managing persistent storage of key-value pairs. +abstract class Preferences implements Service { + /// Stores a string value with the given [key]. + Future setString(String key, String value); + + /// Retrieves the string value associated with the given [key]. + Future getString(String key); + + /// Stores an integer value with the given [key]. + Future setInt(String key, int value); + + /// Retrieves the integer value associated with the given [key]. + Future getInt(String key); + + /// Stores a boolean value with the given [key]. + Future setBool(String key, bool value); + + /// Retrieves the boolean value associated with the given [key]. + Future getBool(String key); + + /// Stores a double value with the given [key]. + Future setDouble(String key, double value); + + /// Retrieves the double value associated with the given [key]. + Future getDouble(String key); + + /// Stores a list of strings with the given [key]. + Future setStringList(String key, List value); + + /// Retrieves the list of strings associated with the given [key]. + Future?> getStringList(String key); + + /// Removes the key-value pair associated with the given [key]. + Future remove(String key); + + /// Clears all key-value pairs in the preferences. + Future clear(); +} diff --git a/finlog_app/fluttery/lib/secure_storage.dart b/finlog_app/fluttery/lib/secure_storage.dart new file mode 100644 index 0000000..af89b8c --- /dev/null +++ b/finlog_app/fluttery/lib/secure_storage.dart @@ -0,0 +1,75 @@ +import 'package:fluttery/fluttery.dart'; + +/// Interface for secure storage operations. +/// +/// Provides methods for securely storing and retrieving sensitive data +/// like passwords, tokens, API keys, etc. Data stored through this interface +/// is encrypted and stored in the device's secure storage (Keychain on iOS, +/// Keystore on Android). +abstract class SecureStorage implements Service { + /// Stores a string value securely with the given [key]. + /// + /// Returns a Future that completes when the value is successfully stored. + /// Throws an exception if the storage operation fails. + Future write(String key, String value); + + /// Retrieves the securely stored string value for the given [key]. + /// + /// Returns the stored value if found, null otherwise. + /// Throws an exception if the retrieval operation fails. + Future read(String key); + + /// Stores an integer value securely with the given [key]. + /// + /// The integer is converted to a string for storage. + Future writeInt(String key, int value); + + /// Retrieves the securely stored integer value for the given [key]. + /// + /// Returns the stored integer if found and valid, null otherwise. + Future readInt(String key); + + /// Stores a boolean value securely with the given [key]. + /// + /// The boolean is converted to a string for storage. + Future writeBool(String key, bool value); + + /// Retrieves the securely stored boolean value for the given [key]. + /// + /// Returns the stored boolean if found and valid, null otherwise. + Future readBool(String key); + + /// Stores a double value securely with the given [key]. + /// + /// The double is converted to a string for storage. + Future writeDouble(String key, double value); + + /// Retrieves the securely stored double value for the given [key]. + /// + /// Returns the stored double if found and valid, null otherwise. + Future readDouble(String key); + + /// Removes the securely stored value for the given [key]. + /// + /// Returns a Future that completes when the value is successfully removed. + Future delete(String key); + + /// Removes all securely stored values. + /// + /// Returns a Future that completes when all values are successfully removed. + /// Use with caution as this operation cannot be undone. + Future deleteAll(); + + /// Returns all keys incl. values in secure storage + Future> readAll(); + + /// Returns all keys currently stored in secure storage. + /// + /// Useful for debugging or migration purposes. + Future> readAllKeys(); + + /// Checks if a value exists for the given [key]. + /// + /// Returns true if a value exists, false otherwise. + Future containsKey(String key); +} diff --git a/finlog_app/fluttery/lib/src/logger/logger_impl.dart b/finlog_app/fluttery/lib/src/logger/logger_impl.dart new file mode 100644 index 0000000..a9429b4 --- /dev/null +++ b/finlog_app/fluttery/lib/src/logger/logger_impl.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:fluttery/logger.dart'; +import 'package:logging/logging.dart' as lib; + +// ignore_for_file: avoid_print +class LoggerImpl implements Logger { + final lib.Logger _logger; + + // coverage:ignore-start + /// Constructor + LoggerImpl() : _logger = lib.Logger("Logger") { + _logger.onRecord.listen((lib.LogRecord record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + if (record.error != null) { + print('Error: ${record.error}'); + } + if (record.stackTrace != null) { + print('Stack Trace: ${record.stackTrace}'); + } + }); + } + + // coverage:ignore-end + @visibleForTesting + factory LoggerImpl.forTest(lib.Logger logger) { + final instance = LoggerImpl._internal(logger); + return instance; + } + + // Private internal constructor + LoggerImpl._internal(this._logger); + + @override + void info(String message) { + _logger.info(message); + } + + @override + void warning(String message) { + _logger.warning(message); + } + + @override + void error(String message, [Object? error, StackTrace? stackTrace]) { + _logger.severe(message, error, stackTrace); + } + + @override + void debug(String message) { + _logger.fine(message); + } + + @override + void setLogLevel(lib.Level level) { + _logger.level = level; + } +} diff --git a/finlog_app/fluttery/lib/src/preferences/preferences_impl.dart b/finlog_app/fluttery/lib/src/preferences/preferences_impl.dart new file mode 100644 index 0000000..5b8d7b1 --- /dev/null +++ b/finlog_app/fluttery/lib/src/preferences/preferences_impl.dart @@ -0,0 +1,86 @@ +import 'package:fluttery/preferences.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class PreferencesImpl implements Preferences { + late final SharedPreferences _prefs; + bool _initialized = false; + + Future _ensureInitialized() async { + if (!_initialized) { + _prefs = await SharedPreferences.getInstance(); + _initialized = true; + } + } + + @override + Future setString(String key, String value) async { + await _ensureInitialized(); + await _prefs.setString(key, value); + } + + @override + Future getString(String key) async { + await _ensureInitialized(); + return _prefs.getString(key); + } + + @override + Future setInt(String key, int value) async { + await _ensureInitialized(); + await _prefs.setInt(key, value); + } + + @override + Future getInt(String key) async { + await _ensureInitialized(); + return _prefs.getInt(key); + } + + @override + Future setBool(String key, bool value) async { + await _ensureInitialized(); + await _prefs.setBool(key, value); + } + + @override + Future getBool(String key) async { + await _ensureInitialized(); + return _prefs.getBool(key); + } + + @override + Future setDouble(String key, double value) async { + await _ensureInitialized(); + await _prefs.setDouble(key, value); + } + + @override + Future getDouble(String key) async { + await _ensureInitialized(); + return _prefs.getDouble(key); + } + + @override + Future setStringList(String key, List value) async { + await _ensureInitialized(); + await _prefs.setStringList(key, value); + } + + @override + Future?> getStringList(String key) async { + await _ensureInitialized(); + return _prefs.getStringList(key); + } + + @override + Future remove(String key) async { + await _ensureInitialized(); + await _prefs.remove(key); + } + + @override + Future clear() async { + await _ensureInitialized(); + await _prefs.clear(); + } +} \ No newline at end of file diff --git a/finlog_app/fluttery/lib/src/storage/secure/secure_storage_impl.dart b/finlog_app/fluttery/lib/src/storage/secure/secure_storage_impl.dart new file mode 100644 index 0000000..df5b7f2 --- /dev/null +++ b/finlog_app/fluttery/lib/src/storage/secure/secure_storage_impl.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fluttery/secure_storage.dart'; + +class SecureStorageImpl implements SecureStorage { + final FlutterSecureStorage _secureStorage; + + /// Constructor - creates a single instance with default FlutterSecureStorage + SecureStorageImpl() : _secureStorage = const FlutterSecureStorage(); + + /// Testing constructor + @visibleForTesting + SecureStorageImpl.forTesting({required FlutterSecureStorage instance}) + : _secureStorage = instance; + + @override + Future write(String key, String value) async { + await _secureStorage.write(key: key, value: value); + } + + @override + Future read(String key) async { + return await _secureStorage.read(key: key); + } + + @override + Future containsKey(String key) async { + return await _secureStorage.containsKey(key: key); + } + + @override + Future delete(String key) async { + await _secureStorage.delete(key: key); + } + + @override + Future deleteAll() async { + await _secureStorage.deleteAll(); + } + + @override + Future> readAll() async { + final allData = await _secureStorage.readAll(); + return allData.keys.toSet(); + } + + @override + Future> readAllKeys() async { + final allData = await _secureStorage.readAll(); + return allData.keys.toSet(); + } + + @override + Future writeInt(String key, int value) async { + await _secureStorage.write(key: key, value: value.toString()); + } + + @override + Future readInt(String key) async { + final value = await _secureStorage.read(key: key); + if (value == null) return null; + return int.tryParse(value); + } + + @override + Future writeBool(String key, bool value) async { + await _secureStorage.write(key: key, value: value.toString()); + } + + @override + Future readBool(String key) async { + final value = await _secureStorage.read(key: key); + if (value == null) return null; + if (value.toLowerCase() == 'true') return true; + if (value.toLowerCase() == 'false') return false; + return null; // Invalid boolean value + } + + @override + Future writeDouble(String key, double value) async { + await _secureStorage.write(key: key, value: value.toString()); + } + + @override + Future readDouble(String key) async { + final value = await _secureStorage.read(key: key); + if (value == null) return null; + return double.tryParse(value); + } +} diff --git a/finlog_app/fluttery/lib/src/system/environment/environment_impl.dart b/finlog_app/fluttery/lib/src/system/environment/environment_impl.dart new file mode 100644 index 0000000..4395af2 --- /dev/null +++ b/finlog_app/fluttery/lib/src/system/environment/environment_impl.dart @@ -0,0 +1,42 @@ +import 'dart:io' show Platform; +import 'package:flutter/foundation.dart' + show kDebugMode, kReleaseMode, kProfileMode; + +import 'package:fluttery/environment.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class EnvironmentImpl implements Environment { + PackageInfo? _packageInfo; + + @override + bool get isAndroid => Platform.isAndroid; + + @override + bool get isIOS => Platform.isIOS; + + @override + Future loadPackageInfo() async { + _packageInfo = await PackageInfo.fromPlatform(); + } + + @override + String get appName => _packageInfo?.appName ?? 'Unknown'; + + @override + String get packageName => _packageInfo?.packageName ?? 'Unknown'; + + @override + String get version => _packageInfo?.version ?? '0.0.0'; + + @override + String get buildNumber => _packageInfo?.buildNumber ?? '0'; + + @override + bool get isDebug => kDebugMode; + + @override + bool get isRelease => kReleaseMode; + + @override + bool get isProfile => kProfileMode; +} diff --git a/finlog_app/fluttery/lib/src/system/worker/worker_impl.dart b/finlog_app/fluttery/lib/src/system/worker/worker_impl.dart new file mode 100644 index 0000000..6807458 --- /dev/null +++ b/finlog_app/fluttery/lib/src/system/worker/worker_impl.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'package:flutter/services.dart' + show ServicesBinding, RootIsolateToken, BackgroundIsolateBinaryMessenger; +import 'package:fluttery/fluttery.dart'; +import 'package:fluttery/logger.dart'; +import 'package:fluttery/worker.dart'; + +class WorkerImpl implements Worker { + final Logger _logger; + + WorkerImpl({ + this.defaultTimeout, + this.maxHistory = 100, + RootIsolateToken? rootToken, + }) : _rootToken = rootToken ?? ServicesBinding.rootIsolateToken, + _logger = App.service(); + + final Duration? defaultTimeout; + final int maxHistory; + + // Captured from the root isolate (may be null in some test envs) + final RootIsolateToken? _rootToken; + + final Map _active = {}; + final List _history = []; + + @override + Future spawn( + String debugName, + FutureOr Function() task, { + void Function()? preTask, + Duration? timeout, + }) { + final id = _generateWorkerId(); + final started = DateTime.now(); + + _logger.debug('Spawning worker "$debugName" ($id)'); + _registerActiveWorker(id, debugName, started); + + final future = _executeWithTimeout( + id, + debugName, + task, + preTask, + timeout ?? defaultTimeout, + ); + + _attachCompletionHandlers(id, debugName, future); + + return future; + } + + String _generateWorkerId() { + return 'iso-${DateTime.now().millisecondsSinceEpoch}'; + } + + void _registerActiveWorker(String id, String debugName, DateTime started) { + _active[id] = WorkerInfo( + id: id, + name: debugName, + startedAt: started, + status: WorkerStatus.running, + ); + _logger.debug('Registered worker "$debugName" ($id)'); + } + + Future _executeWithTimeout( + String id, + String debugName, + FutureOr Function() task, + void Function()? preTask, + Duration? timeout, + ) { + _logger.debug( + 'Executing worker "$debugName" ($id) with timeout: ${timeout?.inSeconds ?? "none"} seconds', + ); + final future = _executeInIsolate(debugName, task, preTask); + + return timeout == null ? future : future.timeout(timeout); + } + + Future _executeInIsolate( + String debugName, + FutureOr Function() task, + void Function()? preTask, + ) { + final token = _rootToken; // captured into closure + _logger.debug('Starting isolate for worker "$debugName"'); + + return Isolate.run(() async { + // Initialize platform channels for this background isolate. + if (token != null) { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + } + // Now it's safe to touch plugins (e.g., SharedPreferences). + App.registerDefaultServices(); + if (preTask != null) { + _logger.debug('Executing pre-task for worker "$debugName"'); + preTask(); + } + return await Future.sync(task); + }, debugName: debugName); + } + + void _attachCompletionHandlers( + String id, + String debugName, + Future future, + ) { + future + .then((_) { + _logger.debug('Worker "$debugName" ($id) completed successfully'); + _finish(id, status: WorkerStatus.completed); + }) + .catchError((e, st) { + final status = e is TimeoutException + ? WorkerStatus.timedOut + : WorkerStatus.failed; + + _finish(id, status: status, error: e, stack: st); + _logWorkerError(debugName, id, e, st); + }); + } + + void _logWorkerError( + String debugName, + String id, + Object error, + StackTrace stackTrace, + ) { + // Best-effort logging + try { + App.service().error( + 'Worker job "$debugName" ($id) failed: $error', + stackTrace, + ); + } catch (_) {} + } + + void _finish( + String id, { + required WorkerStatus status, + Object? error, + StackTrace? stack, + }) { + final prev = _active.remove(id); + final endedAt = DateTime.now(); + final info = WorkerInfo( + id: prev?.id ?? id, + name: prev?.name ?? 'unknown', + startedAt: prev?.startedAt ?? endedAt, + status: status, + endedAt: endedAt, + error: error, + stackTrace: stack, + ); + _history.insert(0, info); + if (_history.length > maxHistory) { + _history.removeRange(maxHistory, _history.length); + } + _logger.debug('Worker "${prev?.name}" ($id) finished with status: $status'); + } + + @override + List getActiveWorkers() => + _active.values.toList() + ..sort((a, b) => a.startedAt.compareTo(b.startedAt)); + + @override + List getAllWorkers() => [...getActiveWorkers(), ..._history]; + + @override + WorkerInfo? getWorker(String id) { + final active = _active[id]; + if (active != null) return active; + for (final w in _history) { + if (w.id == id) return w; + } + return null; + } + + @override + void purge({Duration maxAge = const Duration(minutes: 30)}) { + final cutoff = DateTime.now().subtract(maxAge); + _logger.debug('Purging workers older than $maxAge'); + _history.removeWhere((w) => (w.endedAt ?? w.startedAt).isBefore(cutoff)); + } +} diff --git a/finlog_app/fluttery/lib/worker.dart b/finlog_app/fluttery/lib/worker.dart new file mode 100644 index 0000000..afa2fa5 --- /dev/null +++ b/finlog_app/fluttery/lib/worker.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'package:fluttery/fluttery.dart'; + +abstract class Worker extends Service { + Future spawn( + String debugName, + FutureOr Function() task, { + void Function()? preTask, + Duration? timeout, // per-job override + }); + + /// Currently running jobs. + List getActiveWorkers(); + + /// All known jobs (active + completed + failed), up to a capped history. + List getAllWorkers(); + + /// Optional: get a single worker by id. + WorkerInfo? getWorker(String id); + + /// Remove completed/failed jobs older than [maxAge] from history. + void purge({Duration maxAge = const Duration(minutes: 30)}); +} + +enum WorkerStatus { running, completed, failed, timedOut } + +class WorkerInfo { + WorkerInfo({ + required this.id, + required this.name, + required this.startedAt, + required this.status, + this.endedAt, + this.error, + this.stackTrace, + }); + + final String id; + final String name; + final DateTime startedAt; + final WorkerStatus status; + final DateTime? endedAt; + final Object? error; + final StackTrace? stackTrace; + + Duration get duration => ((endedAt ?? DateTime.now()).difference(startedAt)); +} diff --git a/finlog_app/fluttery/pubspec.yaml b/finlog_app/fluttery/pubspec.yaml index 6c6bdb1..5799668 100644 --- a/finlog_app/fluttery/pubspec.yaml +++ b/finlog_app/fluttery/pubspec.yaml @@ -11,6 +11,12 @@ environment: dependencies: flutter: sdk: flutter + flutter_secure_storage: ^9.2.4 + kiwi: ^5.0.1 + logging: ^1.3.0 + mocktail: ^1.0.4 + package_info_plus: ^9.0.0 + shared_preferences: ^2.5.3 dev_dependencies: flutter_test: diff --git a/finlog_app/fluttery/test/fluttery_test.dart b/finlog_app/fluttery/test/fluttery_test.dart index be69fe2..af878ab 100644 --- a/finlog_app/fluttery/test/fluttery_test.dart +++ b/finlog_app/fluttery/test/fluttery_test.dart @@ -1,12 +1,27 @@ -import 'package:flutter_test/flutter_test.dart'; - import 'package:fluttery/fluttery.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttery/logger.dart'; +import 'package:kiwi/kiwi.dart'; + +import 'mocks/mocks.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); + group('App Service Tests', () { + // Clear the singleton state before each test to ensure isolation + setUp(() { + // KiwiContainer provides a clear method to remove all registered services + KiwiContainer().clear(); + }); + + test('should register and resolve a custom service', () { + // Register a mock logger service + final mockLogger = MockLogger(); + App.registerService(() => mockLogger); + + // Resolve the service and check if it's the same instance + final resolvedLogger = App.service(); + expect(resolvedLogger, isA()); + expect(resolvedLogger, same(mockLogger)); + }); }); } diff --git a/finlog_app/fluttery/test/logger/logger_test.dart b/finlog_app/fluttery/test/logger/logger_test.dart new file mode 100644 index 0000000..60ac934 --- /dev/null +++ b/finlog_app/fluttery/test/logger/logger_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:logging/logging.dart' as lib; +import 'package:fluttery/src/logger/logger_impl.dart'; + +// Mock class for the lib.Logger +class MockLibLogger extends Mock implements lib.Logger {} + +void main() { + final mockLibLogger = MockLibLogger(); + + group('LoggerImpl', () { + late LoggerImpl loggerImpl; + + setUp(() { + loggerImpl = LoggerImpl.forTest(mockLibLogger); + }); + + test('info method logs an info message', () { + loggerImpl.info('Info message'); + + // Verify that the info method was called on the mock logger with the correct message + verify(() => mockLibLogger.info('Info message')).called(1); + }); + + test('warning method logs a warning message', () { + loggerImpl.warning('Warning message'); + + // Verify that the warning method was called on the mock logger with the correct message + verify(() => mockLibLogger.warning('Warning message')).called(1); + }); + + test('error method logs an error message with optional parameters', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.current; + + loggerImpl.error('Error message', exception, stackTrace); + + // Verify that the severe method was called on the mock logger with the correct parameters + verify( + () => mockLibLogger.severe('Error message', exception, stackTrace), + ).called(1); + }); + + test('debug method logs a debug message', () { + loggerImpl.debug('Debug message'); + + // Verify that the fine method was called on the mock logger with the correct message + verify(() => mockLibLogger.fine('Debug message')).called(1); + }); + + test('setLogLevel method sets the logger level', () { + // This is to capture the change in the logger level + var capturedLevel = lib.Level.INFO; + + // Capture the setter call + when(() => mockLibLogger.level = any()).thenAnswer((invocation) { + capturedLevel = invocation.positionalArguments.first as lib.Level; + return capturedLevel; + }); + + // Set the log level + loggerImpl.setLogLevel(lib.Level.WARNING); + + // Verify that the logger level is set to the expected level + expect(capturedLevel, lib.Level.WARNING); + }); + }); +} diff --git a/finlog_app/fluttery/test/mocks/mocks.dart b/finlog_app/fluttery/test/mocks/mocks.dart new file mode 100644 index 0000000..82c20fa --- /dev/null +++ b/finlog_app/fluttery/test/mocks/mocks.dart @@ -0,0 +1,25 @@ +import 'package:fluttery/logger.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockLogger extends Mock implements Logger {} + +class MockUtils { + static Logger mockLogger() { + final logger = MockLogger(); + + when(() => logger.debug(any())).thenAnswer((a) { + print("[DEBUG] ${a.positionalArguments[0]}"); + }); + when(() => logger.info(any())).thenAnswer((a) { + print("[INFO] ${a.positionalArguments[0]}"); + }); + when(() => logger.warning(any())).thenAnswer((a) { + print("[WARN] ${a.positionalArguments[0]}"); + }); + when(() => logger.error(any(), any(), any())).thenAnswer((a) { + print("[ERROR] ${a.positionalArguments[0]}\n${a.positionalArguments[2]}"); + }); + + return logger; + } +} diff --git a/finlog_app/fluttery/test/preferences/preferences_test.dart b/finlog_app/fluttery/test/preferences/preferences_test.dart new file mode 100644 index 0000000..49fa28c --- /dev/null +++ b/finlog_app/fluttery/test/preferences/preferences_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttery/src/preferences/preferences_impl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late PreferencesImpl preferences; + + setUp(() async { + // Clear any existing data and set up a fresh in-memory instance + SharedPreferences.setMockInitialValues({}); + + // Create preferences instance that will use the real SharedPreferences + // but with in-memory storage for testing + preferences = PreferencesImpl(); + + // Give time for initialization + await Future.delayed(Duration.zero); + }); + + group('PreferencesImpl Tests', () { + test('setString and getString work with real implementation', () async { + const key = 'testKey'; + const value = 'testValue'; + + await preferences.setString(key, value); + final result = await preferences.getString(key); + + expect(result, value); + }); + + test('setInt and getInt work with real implementation', () async { + const key = 'testKey'; + const value = 42; + + await preferences.setInt(key, value); + final result = await preferences.getInt(key); + + expect(result, value); + }); + + test('setBool and getBool work with real implementation', () async { + const key = 'testKey'; + const value = true; + + await preferences.setBool(key, value); + final result = await preferences.getBool(key); + + expect(result, value); + }); + + test('setDouble and getDouble work with real implementation', () async { + const key = 'testKey'; + const value = 3.14; + + await preferences.setDouble(key, value); + final result = await preferences.getDouble(key); + + expect(result, value); + }); + + test( + 'setStringList and getStringList work with real implementation', + () async { + const key = 'testKey'; + const value = ['one', 'two', 'three']; + + await preferences.setStringList(key, value); + final result = await preferences.getStringList(key); + + expect(result, value); + }, + ); + + test('remove deletes key-value pair', () async { + const key = 'testKey'; + const value = 'testValue'; + + // Set a value first + await preferences.setString(key, value); + expect(await preferences.getString(key), value); + + // Remove it + await preferences.remove(key); + final result = await preferences.getString(key); + + expect(result, isNull); + }); + + test('clear removes all data', () async { + // Set multiple values + await preferences.setString('key1', 'value1'); + await preferences.setInt('key2', 42); + await preferences.setBool('key3', true); + + // Verify they exist + expect(await preferences.getString('key1'), 'value1'); + expect(await preferences.getInt('key2'), 42); + expect(await preferences.getBool('key3'), true); + + // Clear all + await preferences.clear(); + + // Verify they're gone + expect(await preferences.getString('key1'), isNull); + expect(await preferences.getInt('key2'), isNull); + expect(await preferences.getBool('key3'), isNull); + }); + + test('getting non-existent keys returns null', () async { + expect(await preferences.getString('nonExistent'), isNull); + expect(await preferences.getInt('nonExistent'), isNull); + expect(await preferences.getBool('nonExistent'), isNull); + expect(await preferences.getDouble('nonExistent'), isNull); + expect(await preferences.getStringList('nonExistent'), isNull); + }); + + test('can overwrite existing values of the same type', () async { + const key = 'testKey'; + const initialValue = 'initialValue'; + const newValue = 'newValue'; + + await preferences.setString(key, initialValue); + expect(await preferences.getString(key), initialValue); + + await preferences.setString(key, newValue); + expect(await preferences.getString(key), newValue); + }); + + test( + 'different keys can store different data types simultaneously', + () async { + await preferences.setString('stringKey', 'value'); + await preferences.setInt('intKey', 42); + await preferences.setBool('boolKey', true); + await preferences.setDouble('doubleKey', 3.14); + await preferences.setStringList('listKey', ['a', 'b', 'c']); + + expect(await preferences.getString('stringKey'), 'value'); + expect(await preferences.getInt('intKey'), 42); + expect(await preferences.getBool('boolKey'), true); + expect(await preferences.getDouble('doubleKey'), 3.14); + expect(await preferences.getStringList('listKey'), ['a', 'b', 'c']); + }, + ); + + test('values can be overwritten with different data types', () async { + const key = 'testKey'; + + // Store a string value + await preferences.setString(key, 'stringValue'); + expect(await preferences.getString(key), 'stringValue'); + + // Overwrite with an int - this replaces the string value + await preferences.setInt(key, 42); + expect(await preferences.getInt(key), 42); + + // Overwrite with a bool - this replaces the int value + await preferences.setBool(key, true); + expect(await preferences.getBool(key), true); + }); + + test('persistence works across multiple operations', () async { + // Test that values persist through multiple set/get operations + await preferences.setString('key1', 'value1'); + await preferences.setInt('key2', 100); + + expect(await preferences.getString('key1'), 'value1'); + expect(await preferences.getInt('key2'), 100); + + // Modify one value and ensure the other remains + await preferences.setString('key1', 'newValue1'); + expect(await preferences.getString('key1'), 'newValue1'); + expect(await preferences.getInt('key2'), 100); + }); + }); +} diff --git a/finlog_app/fluttery/test/storage/secure/secure_storage_test.dart b/finlog_app/fluttery/test/storage/secure/secure_storage_test.dart new file mode 100644 index 0000000..553046d --- /dev/null +++ b/finlog_app/fluttery/test/storage/secure/secure_storage_test.dart @@ -0,0 +1,352 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fluttery/src/storage/secure/secure_storage_impl.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late SecureStorageImpl secureStorage; + late MockFlutterSecureStorage mockStorage; + + // Single instance of SecureStorageImpl used throughout all tests + setUpAll(() { + mockStorage = MockFlutterSecureStorage(); + secureStorage = SecureStorageImpl.forTesting(instance: mockStorage); + }); + + setUp(() { + // Reset mock between tests + reset(mockStorage); + }); + + group('SecureStorageImpl String Tests', () { + test('write and read string work', () async { + const key = 'testStringKey'; + const value = 'testStringValue'; + + when( + () => mockStorage.write(key: key, value: value), + ).thenAnswer((_) async {}); + when(() => mockStorage.read(key: key)).thenAnswer((_) async => value); + + await secureStorage.write(key, value); + final result = await secureStorage.read(key); + + expect(result, value); + verify(() => mockStorage.write(key: key, value: value)).called(1); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('read returns null for non-existent string key', () async { + const key = 'nonExistentKey'; + + when(() => mockStorage.read(key: key)).thenAnswer((_) async => null); + + final result = await secureStorage.read(key); + + expect(result, isNull); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('can overwrite existing string values', () async { + const key = 'overwriteKey'; + const initialValue = 'initialValue'; + const newValue = 'newValue'; + + when( + () => mockStorage.write(key: key, value: initialValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: key, value: newValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => initialValue); + + await secureStorage.write(key, initialValue); + final firstResult = await secureStorage.read(key); + expect(firstResult, initialValue); + + when(() => mockStorage.read(key: key)).thenAnswer((_) async => newValue); + + await secureStorage.write(key, newValue); + final secondResult = await secureStorage.read(key); + expect(secondResult, newValue); + }); + }); + + group('SecureStorageImpl Integer Tests', () { + test('writeInt and readInt work', () async { + const key = 'testIntKey'; + const value = 42; + const stringValue = '42'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeInt(key, value); + final result = await secureStorage.readInt(key); + + expect(result, value); + verify(() => mockStorage.write(key: key, value: stringValue)).called(1); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('readInt returns null for non-existent key', () async { + const key = 'nonExistentIntKey'; + + when(() => mockStorage.read(key: key)).thenAnswer((_) async => null); + + final result = await secureStorage.readInt(key); + + expect(result, isNull); + verify(() => mockStorage.read(key: key)).called(1); + }); + + test('readInt handles negative numbers', () async { + const key = 'negativeIntKey'; + const value = -123; + const stringValue = '-123'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeInt(key, value); + final result = await secureStorage.readInt(key); + + expect(result, value); + }); + + test('readInt returns null for invalid integer string', () async { + const key = 'invalidIntKey'; + const invalidValue = 'not_a_number'; + + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => invalidValue); + + final result = await secureStorage.readInt(key); + + expect(result, isNull); + }); + }); + + group('SecureStorageImpl Boolean Tests', () { + test('writeBool and readBool work with true', () async { + const key = 'testBoolTrueKey'; + const value = true; + const stringValue = 'true'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeBool(key, value); + final result = await secureStorage.readBool(key); + + expect(result, value); + }); + + test('writeBool and readBool work with false', () async { + const key = 'testBoolFalseKey'; + const value = false; + const stringValue = 'false'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeBool(key, value); + final result = await secureStorage.readBool(key); + + expect(result, value); + }); + + test('readBool handles case insensitive true/false', () async { + const key = 'caseInsensitiveKey'; + + // Test "TRUE" + when(() => mockStorage.read(key: key)).thenAnswer((_) async => 'TRUE'); + expect(await secureStorage.readBool(key), true); + + // Test "False" + when(() => mockStorage.read(key: key)).thenAnswer((_) async => 'False'); + expect(await secureStorage.readBool(key), false); + }); + + test('readBool returns null for invalid boolean string', () async { + const key = 'invalidBoolKey'; + const invalidValue = 'maybe'; + + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => invalidValue); + + final result = await secureStorage.readBool(key); + + expect(result, isNull); + }); + }); + + group('SecureStorageImpl Double Tests', () { + test('writeDouble and readDouble work', () async { + const key = 'testDoubleKey'; + const value = 3.14159; + const stringValue = '3.14159'; + + when( + () => mockStorage.write(key: key, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => stringValue); + + await secureStorage.writeDouble(key, value); + final result = await secureStorage.readDouble(key); + + expect(result, value); + }); + + test('readDouble returns null for invalid double string', () async { + const key = 'invalidDoubleKey'; + const invalidValue = 'not_a_double'; + + when( + () => mockStorage.read(key: key), + ).thenAnswer((_) async => invalidValue); + + final result = await secureStorage.readDouble(key); + + expect(result, isNull); + }); + }); + + group('SecureStorageImpl Utility Tests', () { + test('containsKey returns true for existing key', () async { + const key = 'existingKey'; + + when( + () => mockStorage.containsKey(key: key), + ).thenAnswer((_) async => true); + + final exists = await secureStorage.containsKey(key); + + expect(exists, isTrue); + verify(() => mockStorage.containsKey(key: key)).called(1); + }); + + test('containsKey returns false for non-existent key', () async { + const key = 'nonExistentKey'; + + when( + () => mockStorage.containsKey(key: key), + ).thenAnswer((_) async => false); + + final exists = await secureStorage.containsKey(key); + + expect(exists, isFalse); + }); + + test('delete removes key-value pair', () async { + const key = 'deleteTestKey'; + + when(() => mockStorage.delete(key: key)).thenAnswer((_) async {}); + + await secureStorage.delete(key); + + verify(() => mockStorage.delete(key: key)).called(1); + }); + + test('readAll and readAllKeys return all stored keys', () async { + final testData = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}; + + when(() => mockStorage.readAll()).thenAnswer((_) async => testData); + + final allKeys = await secureStorage.readAll(); + final allKeysAlt = await secureStorage.readAllKeys(); + + expect(allKeys, containsAll(testData.keys)); + expect(allKeys.length, testData.length); + expect(allKeysAlt, equals(allKeys)); + }); + + test('deleteAll removes all key-value pairs', () async { + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + await secureStorage.deleteAll(); + + verify(() => mockStorage.deleteAll()).called(1); + }); + }); + + group('SecureStorageImpl Mixed Data Type Tests', () { + test('different data types work correctly', () async { + const stringKey = 'stringKey'; + const intKey = 'intKey'; + const boolKey = 'boolKey'; + const doubleKey = 'doubleKey'; + + const stringValue = 'hello world'; + const intValue = 123; + const boolValue = true; + const doubleValue = 45.67; + + // Mock all write operations + when( + () => mockStorage.write(key: stringKey, value: stringValue), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: intKey, value: intValue.toString()), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: boolKey, value: boolValue.toString()), + ).thenAnswer((_) async {}); + when( + () => mockStorage.write(key: doubleKey, value: doubleValue.toString()), + ).thenAnswer((_) async {}); + + // Mock all read operations + when( + () => mockStorage.read(key: stringKey), + ).thenAnswer((_) async => stringValue); + when( + () => mockStorage.read(key: intKey), + ).thenAnswer((_) async => intValue.toString()); + when( + () => mockStorage.read(key: boolKey), + ).thenAnswer((_) async => boolValue.toString()); + when( + () => mockStorage.read(key: doubleKey), + ).thenAnswer((_) async => doubleValue.toString()); + + // Store different types + await secureStorage.write(stringKey, stringValue); + await secureStorage.writeInt(intKey, intValue); + await secureStorage.writeBool(boolKey, boolValue); + await secureStorage.writeDouble(doubleKey, doubleValue); + + // Retrieve and verify all types + expect(await secureStorage.read(stringKey), stringValue); + expect(await secureStorage.readInt(intKey), intValue); + expect(await secureStorage.readBool(boolKey), boolValue); + expect(await secureStorage.readDouble(doubleKey), doubleValue); + }); + }); +} diff --git a/finlog_app/fluttery/test/system/environment/environment_test.dart b/finlog_app/fluttery/test/system/environment/environment_test.dart new file mode 100644 index 0000000..142351a --- /dev/null +++ b/finlog_app/fluttery/test/system/environment/environment_test.dart @@ -0,0 +1,54 @@ +import 'dart:io' show Platform; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttery/src/system/environment/environment_impl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +void main() { + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Test App', + packageName: 'com.example.testapp', + version: '1.2.3', + buildNumber: '42', + buildSignature: 'mock-signature', + installerStore: 'mock-store', + ); + }); + + group('EnvironmentImpl', () { + test('defaults before loadPackageInfo()', () { + final env = EnvironmentImpl(); + + expect(env.appName, equals('Unknown')); + expect(env.packageName, equals('Unknown')); + expect(env.version, equals('0.0.0')); + expect(env.buildNumber, equals('0')); + }); + + test('loadPackageInfo() populates fields from PackageInfo', () async { + final env = EnvironmentImpl(); + await env.loadPackageInfo(); + + expect(env.appName, equals('Test App')); + expect(env.packageName, equals('com.example.testapp')); + expect(env.version, equals('1.2.3')); + expect(env.buildNumber, equals('42')); + }); + + test('platform flags mirror dart:io Platform', () { + final env = EnvironmentImpl(); + + expect(env.isAndroid, equals(Platform.isAndroid)); + expect(env.isIOS, equals(Platform.isIOS)); + }); + + test('build mode flags reflect Flutter constants', () { + final env = EnvironmentImpl(); + + // In unit tests, this should typically be: debug=true, release/profile=false. + expect(env.isDebug, isTrue); + expect(env.isRelease, isFalse); + expect(env.isProfile, isFalse); + }); + }); +} diff --git a/finlog_app/fluttery/test/system/worker/worker_impl_test.dart b/finlog_app/fluttery/test/system/worker/worker_impl_test.dart new file mode 100644 index 0000000..87d1719 --- /dev/null +++ b/finlog_app/fluttery/test/system/worker/worker_impl_test.dart @@ -0,0 +1,413 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttery/fluttery.dart'; +import 'package:fluttery/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fluttery/src/system/worker/worker_impl.dart'; +import 'package:fluttery/worker.dart'; + +import '../../mocks/mocks.dart'; + +Future pumpMicro([int times = 10]) => pumpEventQueue(times: times); + +Future waitFor( + bool Function() predicate, { + Duration timeout = const Duration(seconds: 2), + Duration step = const Duration(milliseconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (predicate()) return; + await Future.delayed(step); + } + fail('Condition not met within $timeout'); +} + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + expect(ServicesBinding.rootIsolateToken, isNotNull); + + App.registerService(() => MockUtils.mockLogger()); + }); + + group('WorkerImpl', () { + late WorkerImpl worker; + + setUp(() { + worker = WorkerImpl(); + SharedPreferences.setMockInitialValues({}); + }); + + test('spawn returns value; preTask runs; active->history tracking', () async { + // We'll verify preTask runs by checking the task itself can access + // what the preTask sets up (SharedPreferences mock) + final future = worker.spawn( + 'successful_task', + () async { + await Future.delayed(const Duration(milliseconds: 20)); + + // This would fail if preTask didn't run to set up SharedPreferences mock + SharedPreferences.setMockInitialValues({'test': 'verified'}); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('preTaskRan', 'true'); + + return 42; + }, + preTask: () { + // Set up the SharedPreferences mock so the task can use it + SharedPreferences.setMockInitialValues({}); + }, + ); + + // Verify worker is registered as active shortly after spawn + await Future.delayed(const Duration(milliseconds: 10)); + expect(worker.getActiveWorkers().length, 1); + + final activeWorkers = worker.getActiveWorkers(); + expect(activeWorkers.first.name, 'successful_task'); + expect(activeWorkers.first.status, WorkerStatus.running); + + // Wait for completion + final result = await future; + expect(result, 42); + + // The fact that the task completed successfully without throwing an exception + // when trying to use SharedPreferences proves that preTask ran + + // Wait for the completion handlers to run and move worker to history + await waitFor(() => worker.getActiveWorkers().isEmpty); + + // Verify the worker was moved to history with completed status + final historyWorkers = worker.getAllWorkers(); + expect(historyWorkers.length, 1); + expect(historyWorkers.first.status, WorkerStatus.completed); + expect(historyWorkers.first.name, 'successful_task'); + expect(historyWorkers.first.endedAt, isNotNull); + }); + test('timeout marks job as timedOut and throws TimeoutException', () async { + final timedWorker = WorkerImpl( + defaultTimeout: const Duration(milliseconds: 50), + ); + + await expectLater( + timedWorker.spawn( + 'timeout_task', + () async => Future.delayed(const Duration(milliseconds: 200)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ), + throwsA(isA()), + ); + + // Wait for worker to update history + await waitFor(() => timedWorker.getAllWorkers().isNotEmpty); + + final allWorkers = timedWorker.getAllWorkers(); + expect(allWorkers.first.status, WorkerStatus.timedOut); + expect(allWorkers.first.name, 'timeout_task'); + }); + + test('custom timeout overrides default timeout', () async { + final timedWorker = WorkerImpl( + defaultTimeout: const Duration(milliseconds: 200), // Long default + ); + + await expectLater( + timedWorker.spawn( + 'custom_timeout_task', + () async => Future.delayed(const Duration(milliseconds: 100)), + timeout: const Duration(milliseconds: 50), // Short custom timeout + preTask: () => SharedPreferences.setMockInitialValues({}), + ), + throwsA(isA()), + ); + + await waitFor(() => timedWorker.getAllWorkers().isNotEmpty); + expect(timedWorker.getAllWorkers().first.status, WorkerStatus.timedOut); + }); + + test('failure marks job as failed and surfaces exception', () async { + // Create a variable to capture the actual exception + Object? caughtException; + + try { + await worker.spawn('failing_task', () async { + await Future.delayed(const Duration(milliseconds: 10)); + throw StateError('intentional failure'); + }, preTask: () => SharedPreferences.setMockInitialValues({})); + fail('Expected an exception to be thrown'); + } catch (e) { + caughtException = e; + } + + // Verify that an exception was thrown (could be RemoteError or the original StateError) + expect(caughtException, isNotNull); + expect( + caughtException is RemoteError || caughtException is StateError, + isTrue, + reason: + 'Should throw either RemoteError or StateError, got: ${caughtException.runtimeType}', + ); + + // Wait for worker to update history + await waitFor(() => worker.getAllWorkers().isNotEmpty); + + final allWorkers = worker.getAllWorkers(); + expect(allWorkers.first.status, WorkerStatus.failed); + expect(allWorkers.first.name, 'failing_task'); + expect(allWorkers.first.error, isNotNull); + }); + + test('getWorker finds active and completed workers by ID', () async { + final future = worker.spawn('trackable_task', () async { + await Future.delayed(const Duration(milliseconds: 50)); + return 123; + }, preTask: () => SharedPreferences.setMockInitialValues({})); + + // Find worker while active + await Future.delayed(const Duration(milliseconds: 10)); + final activeWorkers = worker.getActiveWorkers(); + expect(activeWorkers.length, 1); + + final workerId = activeWorkers.first.id; + final activeWorker = worker.getWorker(workerId); + expect(activeWorker, isNotNull); + expect(activeWorker!.status, WorkerStatus.running); + + // Wait for completion + await future; + await waitFor(() => worker.getActiveWorkers().isEmpty); + + // Find worker in history + final completedWorker = worker.getWorker(workerId); + expect(completedWorker, isNotNull); + expect(completedWorker!.status, WorkerStatus.completed); + }); + + test('getWorker returns null for non-existent ID', () { + expect(worker.getWorker('non-existent'), isNull); + expect(worker.getWorker('999999'), isNull); + }); + + test('worker ID generation uses timestamp format', () async { + final futures = []; + final Set generatedIds = {}; + + // Spawn multiple workers with sufficient delay to ensure unique timestamps + for (int i = 0; i < 3; i++) { + futures.add( + worker.spawn( + 'task_$i', + () async => Future.delayed(const Duration(milliseconds: 10)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ), + ); + // Ensure sufficient delay for different timestamps + await Future.delayed(const Duration(milliseconds: 1)); + } + + // Wait for all workers to be registered as active + await waitFor(() => worker.getActiveWorkers().length == 3); + + final activeWorkers = worker.getActiveWorkers(); + expect(activeWorkers.length, 3); + + // Verify each ID follows the timestamp format and is unique + for (final workerInfo in activeWorkers) { + expect(workerInfo.id, startsWith('iso-')); + expect( + generatedIds.contains(workerInfo.id), + isFalse, + reason: 'Worker ID should be unique: ${workerInfo.id}', + ); + generatedIds.add(workerInfo.id); + + final timestampPart = workerInfo.id.substring( + 4, + ); // Remove 'iso-' prefix + final timestamp = int.tryParse(timestampPart); + expect( + timestamp, + isNotNull, + reason: 'Timestamp part should be a valid integer: $timestampPart', + ); + expect( + timestamp, + greaterThan(0), + reason: 'Timestamp should be positive: $timestamp', + ); + + // Verify timestamp is reasonable (not too old, not in future) + final now = DateTime.now().millisecondsSinceEpoch; + expect( + timestamp, + lessThanOrEqualTo(now), + reason: 'Timestamp should not be in the future', + ); + expect( + timestamp, + greaterThan(now - 10000), + reason: 'Timestamp should be recent (within 10 seconds)', + ); + } + + // Verify we generated 3 unique IDs + expect(generatedIds.length, 3); + + await Future.wait(futures); + }); + + test('getAllWorkers combines active and history workers', () async { + // Spawn and complete a short task first + await worker.spawn( + 'short_task', + () async => Future.delayed(const Duration(milliseconds: 10)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ); + + // Wait for short task to complete and move to history + await waitFor(() => worker.getAllWorkers().length == 1); + await waitFor(() => worker.getActiveWorkers().isEmpty); + + // Verify we have one completed worker in history + expect(worker.getAllWorkers().length, 1); + expect(worker.getAllWorkers().first.status, WorkerStatus.completed); + expect(worker.getAllWorkers().first.name, 'short_task'); + + // Now spawn a long-running task + final longTask = worker.spawn( + 'long_task', + () async => Future.delayed(const Duration(milliseconds: 100)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ); + + // Wait briefly for long task to be registered as active + await Future.delayed(const Duration(milliseconds: 10)); + + // Now we should have 2 workers: 1 active (long_task) and 1 in history (short_task) + final allWorkers = worker.getAllWorkers(); + expect(allWorkers.length, 2); + + final activeCount = allWorkers + .where((w) => w.status == WorkerStatus.running) + .length; + final completedCount = allWorkers + .where((w) => w.status == WorkerStatus.completed) + .length; + + expect(activeCount, 1); + expect(completedCount, 1); + + // Verify the active worker is the long task + final activeWorkers = worker.getActiveWorkers(); + expect(activeWorkers.length, 1); + expect(activeWorkers.first.name, 'long_task'); + + await longTask; + }); + + test('purge removes old workers from history', () async { + // Complete a task + await worker.spawn( + 'old_task', + () async => Future.delayed(const Duration(milliseconds: 10)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ); + + await waitFor(() => worker.getAllWorkers().isNotEmpty); + expect(worker.getAllWorkers().length, 1); + + // Purge with zero max age (removes everything) + worker.purge(maxAge: Duration.zero); + await pumpMicro(); + + expect(worker.getAllWorkers(), isEmpty); + }); + + test('maxHistory limit is enforced', () async { + final limitedWorker = WorkerImpl(maxHistory: 2); + + // Complete 3 tasks + for (int i = 0; i < 3; i++) { + await limitedWorker.spawn( + 'task_$i', + () async => Future.delayed(const Duration(milliseconds: 5)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ); + await waitFor(() => limitedWorker.getActiveWorkers().isEmpty); + } + + // Should only keep the last 2 in history + final allWorkers = limitedWorker.getAllWorkers(); + expect(allWorkers.length, 2); + + // Should be the most recent tasks (task_1 and task_2) + final names = allWorkers.map((w) => w.name).toSet(); + expect(names.contains('task_1'), isTrue); + expect(names.contains('task_2'), isTrue); + expect(names.contains('task_0'), isFalse); + }); + + test( + 'no timeout when defaultTimeout is null and timeout is null', + () async { + final noTimeoutWorker = WorkerImpl(defaultTimeout: null); + + final result = await noTimeoutWorker.spawn( + 'no_timeout_task', + () async { + await Future.delayed(const Duration(milliseconds: 50)); + return 999; + }, + preTask: () => SharedPreferences.setMockInitialValues({}), + ); + + expect(result, 999); + + await waitFor(() => noTimeoutWorker.getAllWorkers().isNotEmpty); + expect( + noTimeoutWorker.getAllWorkers().first.status, + WorkerStatus.completed, + ); + }, + ); + + test('active workers are sorted by start time', () async { + final futures = []; + + // Spawn workers with small delays between them + for (int i = 0; i < 3; i++) { + futures.add( + worker.spawn( + 'timed_task_$i', + () async => Future.delayed(const Duration(milliseconds: 100)), + preTask: () => SharedPreferences.setMockInitialValues({}), + ), + ); + await Future.delayed(const Duration(milliseconds: 10)); + } + + final activeWorkers = worker.getActiveWorkers(); + expect(activeWorkers.length, 3); + + // Verify they are sorted by start time (earliest first) + for (int i = 1; i < activeWorkers.length; i++) { + expect( + activeWorkers[i - 1].startedAt.isBefore(activeWorkers[i].startedAt) || + activeWorkers[i - 1].startedAt.isAtSameMomentAs( + activeWorkers[i].startedAt, + ), + isTrue, + reason: 'Workers should be sorted by start time', + ); + } + + await Future.wait(futures); + }); + }); +}