From 1e51e913df72dbb958baff2b0371449c24fdc1be Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Thu, 24 Jul 2025 19:42:16 +0200 Subject: [PATCH 01/21] feat(json_handler): implement parseJsonString method with error handling --- lib/src/utils/json_handler.dart | 99 ++++++++++ test/utils/json_handler_test.dart | 312 ++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) diff --git a/lib/src/utils/json_handler.dart b/lib/src/utils/json_handler.dart index 090f0f1..eada92c 100644 --- a/lib/src/utils/json_handler.dart +++ b/lib/src/utils/json_handler.dart @@ -1,6 +1,7 @@ import 'package:flutter_policy_engine/src/exceptions/json_parse_exception.dart'; import 'package:flutter_policy_engine/src/exceptions/json_serialize_exception.dart'; import 'package:flutter_policy_engine/src/utils/log_handler.dart'; +import 'dart:convert'; /// Utility for type-safe JSON conversions with generic support. /// @@ -262,4 +263,102 @@ class JsonHandler { return null; } } + + /// Parses a JSON string into a Map<String, dynamic> with comprehensive error handling. + /// + /// This function safely converts a JSON string representation into a strongly-typed + /// map structure. It includes validation to ensure the parsed result is actually + /// a map and provides detailed error information for debugging. + /// + /// The function handles various error scenarios: + /// - Invalid JSON syntax + /// - JSON that doesn't represent an object (e.g., arrays, primitives) + /// - Empty or null input strings + /// + /// Returns a [Map<String, dynamic>] containing the parsed JSON data. + /// + /// Throws [JsonParseException] if the JSON string cannot be parsed or doesn't + /// represent a valid JSON object. + /// + /// Example usage: + /// ```dart + /// try { + /// final jsonString = '{"name": "John", "age": 30}'; + /// final result = JsonHandler.parseJsonString(jsonString); + /// print(result['name']); // Output: John + /// } catch (e) { + /// print('Failed to parse JSON: $e'); + /// } + /// ``` + static Map parseJsonString( + String jsonString, { + String? context, + }) { + LogHandler.debug( + 'Starting JSON string parsing', + context: { + 'input_length': jsonString.length, + 'context': context ?? 'unknown', + }, + operation: 'json_parse_string', + ); + + try { + // Validate input + if (jsonString.isEmpty) { + throw JsonParseException( + 'Cannot parse empty JSON string', + ); + } + + // Parse JSON string + final dynamic parsed = jsonDecode(jsonString); + + // Validate that the parsed result is a map + if (parsed is! Map) { + throw JsonParseException( + 'JSON string does not represent a valid object. ' + 'Expected Map, got ${parsed.runtimeType}', + originalError: TypeError(), + ); + } + + LogHandler.debug( + 'Successfully parsed JSON string', + context: { + 'parsed_keys_count': parsed.length, + 'context': context ?? 'unknown', + }, + operation: 'json_parse_string_success', + ); + + return parsed; + } catch (e, stackTrace) { + final errorMessage = 'Failed to parse JSON string: ${e.toString()}'; + + LogHandler.error( + errorMessage, + error: e, + stackTrace: stackTrace, + context: { + 'input_length': jsonString.length, + 'input_preview': jsonString.length > 100 + ? '${jsonString.substring(0, 100)}...' + : jsonString, + 'context': context ?? 'unknown', + }, + operation: 'json_parse_string_error', + ); + + // Re-throw as JsonParseException if it's not already + if (e is JsonParseException) { + rethrow; + } + + throw JsonParseException( + errorMessage, + originalError: e, + ); + } + } } diff --git a/test/utils/json_handler_test.dart b/test/utils/json_handler_test.dart index 004bb90..c1d65b1 100644 --- a/test/utils/json_handler_test.dart +++ b/test/utils/json_handler_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_policy_engine/src/exceptions/json_parse_exception.dart'; import 'package:flutter_policy_engine/src/exceptions/json_serialize_exception.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_policy_engine/src/utils/json_handler.dart'; +import 'dart:convert'; // Added for jsonEncode // Test data classes for JSON conversion testing class TestUser { @@ -540,6 +541,317 @@ void main() { }); }); + group('parseJsonString', () { + test('should parse valid JSON string to map', () { + const jsonString = + '{"name": "John Doe", "age": 30, "hobbies": ["reading", "swimming"], "metadata": {"city": "New York"}}'; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result['name'], 'John Doe'); + expect(result['age'], 30); + expect(result['hobbies'], ['reading', 'swimming']); + expect(result['metadata'], {'city': 'New York'}); + }); + + test('should parse complex nested JSON string', () { + const jsonString = ''' + { + "users": { + "user1": { + "name": "John Doe", + "age": 30, + "hobbies": ["reading", "swimming"], + "metadata": {"city": "New York", "country": "USA"} + }, + "user2": { + "name": "Jane Smith", + "age": 25, + "hobbies": ["coding", "gaming"], + "metadata": {"city": "Los Angeles", "country": "USA"} + } + }, + "settings": { + "theme": "dark", + "notifications": true, + "preferences": { + "language": "en", + "timezone": "UTC" + } + } + } + '''; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result['users'], isA>()); + expect(result['settings'], isA>()); + + final users = result['users'] as Map; + expect((users['user1'] as Map)['name'], 'John Doe'); + expect((users['user2'] as Map)['name'], 'Jane Smith'); + + final settings = result['settings'] as Map; + expect(settings['theme'], 'dark'); + expect(settings['notifications'], true); + expect( + (settings['preferences'] as Map)['language'], + 'en', + ); + }); + + test('should parse JSON string with empty object', () { + const jsonString = '{}'; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result, isEmpty); + }); + + test('should parse JSON string with primitive values', () { + const jsonString = + '{"string": "hello", "number": 42, "boolean": true, "null_value": null}'; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result['string'], 'hello'); + expect(result['number'], 42); + expect(result['boolean'], true); + expect(result['null_value'], isNull); + }); + + test('should parse JSON string with arrays', () { + const jsonString = + '{"numbers": [1, 2, 3, 4, 5], "strings": ["a", "b", "c"], "mixed": [1, "two", true, null]}'; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result['numbers'], [1, 2, 3, 4, 5]); + expect(result['strings'], ['a', 'b', 'c']); + expect(result['mixed'], [1, 'two', true, null]); + }); + + test('should handle whitespace and formatting', () { + const jsonString = ''' + { + "name": "John Doe", + "age": 30, + "hobbies": [ + "reading", + "swimming" + ], + "metadata": { + "city": "New York" + } + } + '''; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result['name'], 'John Doe'); + expect(result['age'], 30); + expect(result['hobbies'], ['reading', 'swimming']); + expect(result['metadata'], {'city': 'New York'}); + }); + + test('should handle JSON string with escaped characters', () { + const jsonString = + '{"message": "Hello\\nWorld", "path": "C:\\\\Users\\\\John", "quote": "He said \\"Hello\\""}'; + + final result = JsonHandler.parseJsonString(jsonString); + + expect(result, isA>()); + expect(result['message'], 'Hello\nWorld'); + expect(result['path'], 'C:\\Users\\John'); + expect(result['quote'], 'He said "Hello"'); + }); + + group('Error handling', () { + test('should throw JsonParseException for empty string', () { + expect( + () => JsonHandler.parseJsonString(''), + throwsA(isA()), + ); + }); + + test('should throw JsonParseException for whitespace-only string', () { + expect( + () => JsonHandler.parseJsonString(' \n\t '), + throwsA(isA()), + ); + }); + + test('should throw JsonParseException for invalid JSON syntax', () { + expect( + () => JsonHandler.parseJsonString( + '{"name": "John", "age": 30,}'), // Trailing comma + throwsA(isA()), + ); + }); + + test('should throw JsonParseException for malformed JSON', () { + expect( + () => JsonHandler.parseJsonString( + '{"name": "John", "age": 30'), // Missing closing brace + throwsA(isA()), + ); + }); + + test('should throw JsonParseException for JSON array instead of object', + () { + expect( + () => JsonHandler.parseJsonString('[1, 2, 3, 4, 5]'), + throwsA(isA()), + ); + }); + + test( + 'should throw JsonParseException for JSON primitive instead of object', + () { + expect( + () => JsonHandler.parseJsonString('"hello world"'), + throwsA(isA()), + ); + }); + + test( + 'should throw JsonParseException for JSON number instead of object', + () { + expect( + () => JsonHandler.parseJsonString('42'), + throwsA(isA()), + ); + }); + + test( + 'should throw JsonParseException for JSON boolean instead of object', + () { + expect( + () => JsonHandler.parseJsonString('true'), + throwsA(isA()), + ); + }); + + test('should throw JsonParseException for JSON null instead of object', + () { + expect( + () => JsonHandler.parseJsonString('null'), + throwsA(isA()), + ); + }); + + test('should throw JsonParseException for non-JSON string', () { + expect( + () => JsonHandler.parseJsonString('This is not JSON'), + throwsA(isA()), + ); + }); + + test('should include context in error handling', () { + try { + JsonHandler.parseJsonString( + 'invalid json', + context: 'test_context', + ); + fail('Expected JsonParseException to be thrown'); + } catch (e) { + expect(e, isA()); + // The context is logged but not included in the exception message + // This is expected behavior for logging vs exception messages + } + }); + }); + + group('Integration with parseMap', () { + test('should handle round-trip: JSON string -> parseMap -> mapToJson', + () { + const jsonString = ''' + { + "user1": { + "name": "John Doe", + "age": 30, + "hobbies": ["reading", "swimming"], + "metadata": {"city": "New York"} + }, + "user2": { + "name": "Jane Smith", + "age": 25, + "hobbies": ["coding", "gaming"], + "metadata": {"city": "Los Angeles"} + } + } + '''; + + // Parse JSON string to map + final jsonMap = JsonHandler.parseJsonString(jsonString); + + // Parse map to typed objects + final users = JsonHandler.parseMap( + jsonMap, + (json) => TestUser.fromJson(json), + ); + + // Serialize back to JSON map + final serializedMap = JsonHandler.mapToJson( + users, + (user) => user.toJson(), + ); + + // Convert back to JSON string for comparison + final resultJsonString = jsonEncode(serializedMap); + + expect(users, hasLength(2)); + expect(users['user1']!.name, 'John Doe'); + expect(users['user2']!.name, 'Jane Smith'); + expect(serializedMap, hasLength(2)); + expect(resultJsonString, contains('"name":"John Doe"')); + expect(resultJsonString, contains('"name":"Jane Smith"')); + }); + + test('should handle partial success with invalid items in JSON string', + () { + const jsonString = ''' + { + "valid_user": { + "name": "John Doe", + "age": 30, + "hobbies": ["reading"], + "metadata": {} + }, + "invalid_user": "not_a_user_object", + "another_valid_user": { + "name": "Jane Smith", + "age": 25, + "hobbies": ["coding"], + "metadata": {} + } + } + '''; + + // Parse JSON string to map + final jsonMap = JsonHandler.parseJsonString(jsonString); + + // Parse map to typed objects with partial success + final users = JsonHandler.parseMap( + jsonMap, + (json) => TestUser.fromJson(json), + allowPartialSuccess: true, + ); + + expect(users, hasLength(2)); + expect(users['valid_user']!.name, 'John Doe'); + expect(users['another_valid_user']!.name, 'Jane Smith'); + expect(users.containsKey('invalid_user'), false); + }); + }); + }); + group('Integration tests', () { test('should handle round-trip conversion for map of objects', () { final originalUsers = { From 0d269e25578a205d2dc23122202400892c2ce4f6 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Thu, 24 Jul 2025 19:53:46 +0200 Subject: [PATCH 02/21] feat(asset_policy_storage): add AssetPolicyStorage class for loading policies from Flutter assets --- lib/src/core/asset_policy_storage.dart | 109 +++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 lib/src/core/asset_policy_storage.dart diff --git a/lib/src/core/asset_policy_storage.dart b/lib/src/core/asset_policy_storage.dart new file mode 100644 index 0000000..5042dc4 --- /dev/null +++ b/lib/src/core/asset_policy_storage.dart @@ -0,0 +1,109 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart'; +import 'package:flutter_policy_engine/src/utils/json_handler.dart'; +import 'package:flutter_policy_engine/src/utils/log_handler.dart'; + +/// A policy storage implementation that loads policies from Flutter assets. +/// +/// This class implements the [IPolicyStorage] interface to provide policy +/// storage functionality using Flutter's asset system. It loads policies +/// from JSON files stored in the app's assets directory. +/// +/// The asset file should contain a valid JSON object with policy definitions. +/// If the asset file cannot be loaded or parsed, an empty map is returned +/// and an error is logged. +/// +/// Example usage: +/// ```dart +/// final storage = AssetPolicyStorage(assetPath: 'assets/policies.json'); +/// final policies = await storage.loadPolicies(); +/// ``` +class AssetPolicyStorage implements IPolicyStorage { + /// Creates an [AssetPolicyStorage] instance. + /// + /// The [assetPath] parameter specifies the path to the JSON asset file + /// relative to the app's assets directory (e.g., 'assets/policies.json'). + /// + /// Throws an [ArgumentError] if [assetPath] is null or empty. + AssetPolicyStorage({ + required String assetPath, + }) : _assetPath = assetPath { + if (assetPath.isEmpty) { + throw ArgumentError('Asset path cannot be empty', 'assetPath'); + } + } + + /// The path to the asset file containing the policies. + final String _assetPath; + + /// Clears all stored policies. + /// + /// This method is not implemented for asset-based storage since assets + /// are read-only. Attempting to call this method will throw an + /// [UnimplementedError]. + /// + /// Throws [UnimplementedError] - Asset storage is read-only. + @override + Future clearPolicies() { + // TODO: implement clearPolicies + throw UnimplementedError(); + } + + /// Loads policies from the specified asset file. + /// + /// Reads the JSON content from the asset file specified in the constructor + /// and parses it into a [Map<String, dynamic>]. The JSON should contain + /// policy definitions in a structured format. + /// + /// The method performs the following operations: + /// 1. Loads the JSON string from the asset file using [rootBundle.loadString] + /// 2. Parses the JSON string into a [Map<String, dynamic>] using [JsonHandler.parseJsonString] + /// 3. Returns the parsed policies map + /// + /// **Error Handling:** + /// - If the asset file cannot be found or read, a [PlatformException] is thrown + /// - If the JSON content is malformed, a [JsonParseException] is thrown + /// - If any other error occurs during loading or parsing, the error is caught, + /// logged via [LogHandler.error], and an empty map is returned + /// + /// **Returns:** A [Map<String, dynamic>] containing the loaded policies. + /// Returns an empty map if the asset file cannot be loaded or parsed. + /// + /// **Example:** + /// ```dart + /// final storage = AssetPolicyStorage(assetPath: 'assets/policies.json'); + /// final policies = await storage.loadPolicies(); + /// print('Loaded ${policies.length} policies'); + /// ``` + /// + /// **Throws:** + /// - [PlatformException] if the asset file cannot be found or read + /// - [JsonParseException] if the JSON content is malformed + @override + Future> loadPolicies() async { + try { + final jsonString = await rootBundle.loadString(_assetPath); + return JsonHandler.parseJsonString(jsonString); + } catch (e) { + LogHandler.error( + 'Failed to load policies from asset: $_assetPath', + error: e, + ); + // Return an empty map with the correct type to satisfy the return type + return {}; + } + } + + /// Saves policies to storage. + /// + /// This method is not implemented for asset-based storage since assets + /// are read-only. Attempting to call this method will throw an + /// [UnimplementedError]. + /// + /// Throws [UnimplementedError] - Asset storage is read-only. + @override + Future savePolicies(Map policies) { + // TODO: implement savePolicies + throw UnimplementedError(); + } +} From e143d35b430bd4267b9cd6c7180591cd0df78352 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Fri, 25 Jul 2025 18:53:42 +0200 Subject: [PATCH 03/21] refactor(asset_policy_storage): rename AssetPolicyStorage class and introduce ExternalAssetHandler --- lib/src/core/asset_policy_storage.dart | 109 ---------------------- lib/src/utils/external_asset_handler.dart | 108 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 109 deletions(-) delete mode 100644 lib/src/core/asset_policy_storage.dart create mode 100644 lib/src/utils/external_asset_handler.dart diff --git a/lib/src/core/asset_policy_storage.dart b/lib/src/core/asset_policy_storage.dart deleted file mode 100644 index 5042dc4..0000000 --- a/lib/src/core/asset_policy_storage.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart'; -import 'package:flutter_policy_engine/src/utils/json_handler.dart'; -import 'package:flutter_policy_engine/src/utils/log_handler.dart'; - -/// A policy storage implementation that loads policies from Flutter assets. -/// -/// This class implements the [IPolicyStorage] interface to provide policy -/// storage functionality using Flutter's asset system. It loads policies -/// from JSON files stored in the app's assets directory. -/// -/// The asset file should contain a valid JSON object with policy definitions. -/// If the asset file cannot be loaded or parsed, an empty map is returned -/// and an error is logged. -/// -/// Example usage: -/// ```dart -/// final storage = AssetPolicyStorage(assetPath: 'assets/policies.json'); -/// final policies = await storage.loadPolicies(); -/// ``` -class AssetPolicyStorage implements IPolicyStorage { - /// Creates an [AssetPolicyStorage] instance. - /// - /// The [assetPath] parameter specifies the path to the JSON asset file - /// relative to the app's assets directory (e.g., 'assets/policies.json'). - /// - /// Throws an [ArgumentError] if [assetPath] is null or empty. - AssetPolicyStorage({ - required String assetPath, - }) : _assetPath = assetPath { - if (assetPath.isEmpty) { - throw ArgumentError('Asset path cannot be empty', 'assetPath'); - } - } - - /// The path to the asset file containing the policies. - final String _assetPath; - - /// Clears all stored policies. - /// - /// This method is not implemented for asset-based storage since assets - /// are read-only. Attempting to call this method will throw an - /// [UnimplementedError]. - /// - /// Throws [UnimplementedError] - Asset storage is read-only. - @override - Future clearPolicies() { - // TODO: implement clearPolicies - throw UnimplementedError(); - } - - /// Loads policies from the specified asset file. - /// - /// Reads the JSON content from the asset file specified in the constructor - /// and parses it into a [Map<String, dynamic>]. The JSON should contain - /// policy definitions in a structured format. - /// - /// The method performs the following operations: - /// 1. Loads the JSON string from the asset file using [rootBundle.loadString] - /// 2. Parses the JSON string into a [Map<String, dynamic>] using [JsonHandler.parseJsonString] - /// 3. Returns the parsed policies map - /// - /// **Error Handling:** - /// - If the asset file cannot be found or read, a [PlatformException] is thrown - /// - If the JSON content is malformed, a [JsonParseException] is thrown - /// - If any other error occurs during loading or parsing, the error is caught, - /// logged via [LogHandler.error], and an empty map is returned - /// - /// **Returns:** A [Map<String, dynamic>] containing the loaded policies. - /// Returns an empty map if the asset file cannot be loaded or parsed. - /// - /// **Example:** - /// ```dart - /// final storage = AssetPolicyStorage(assetPath: 'assets/policies.json'); - /// final policies = await storage.loadPolicies(); - /// print('Loaded ${policies.length} policies'); - /// ``` - /// - /// **Throws:** - /// - [PlatformException] if the asset file cannot be found or read - /// - [JsonParseException] if the JSON content is malformed - @override - Future> loadPolicies() async { - try { - final jsonString = await rootBundle.loadString(_assetPath); - return JsonHandler.parseJsonString(jsonString); - } catch (e) { - LogHandler.error( - 'Failed to load policies from asset: $_assetPath', - error: e, - ); - // Return an empty map with the correct type to satisfy the return type - return {}; - } - } - - /// Saves policies to storage. - /// - /// This method is not implemented for asset-based storage since assets - /// are read-only. Attempting to call this method will throw an - /// [UnimplementedError]. - /// - /// Throws [UnimplementedError] - Asset storage is read-only. - @override - Future savePolicies(Map policies) { - // TODO: implement savePolicies - throw UnimplementedError(); - } -} diff --git a/lib/src/utils/external_asset_handler.dart b/lib/src/utils/external_asset_handler.dart new file mode 100644 index 0000000..9dbae80 --- /dev/null +++ b/lib/src/utils/external_asset_handler.dart @@ -0,0 +1,108 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_policy_engine/src/utils/json_handler.dart'; +import 'package:flutter_policy_engine/src/utils/log_handler.dart'; + +/// Handles loading and parsing of external asset files for policy configuration. +/// +/// This class provides a convenient way to load JSON policy files from Flutter assets +/// and parse them into a structured format. It includes error handling and logging +/// for robust asset loading operations. +/// +/// ## Usage +/// +/// ```dart +/// // Initialize with asset path +/// final assetHandler = ExternalAssetHandler( +/// assetPath: 'assets/policies/config.json', +/// ); +/// +/// // Load and parse the asset +/// final policies = await assetHandler.loadAssets(); +/// ``` +/// +/// ## Error Handling +/// +/// The class automatically handles asset loading errors and returns an empty map +/// if the asset cannot be loaded or parsed. All errors are logged for debugging. +class ExternalAssetHandler { + /// Creates an [ExternalAssetHandler] instance. + /// + /// The [assetPath] parameter specifies the path to the JSON asset file within + /// the Flutter assets directory. This path should be relative to the assets + /// folder and must be declared in the `pubspec.yaml` file. + /// + /// ## Parameters + /// + /// * [assetPath] - The path to the JSON asset file (e.g., 'assets/policies/config.json') + /// + /// ## Throws + /// + /// * [ArgumentError] - If [assetPath] is empty or null + /// + /// ## Example + /// + /// ```dart + /// final handler = ExternalAssetHandler( + /// assetPath: 'assets/policies/user_roles.json', + /// ); + /// ``` + ExternalAssetHandler({ + required String assetPath, + }) : _assetPath = assetPath { + if (assetPath.isEmpty) { + throw ArgumentError('Asset path cannot be empty', 'assetPath'); + } + } + + /// The path to the asset file. + final String _assetPath; + + /// Loads and parses the JSON asset file. + /// + /// This method asynchronously loads the JSON file from the specified asset path + /// and parses it into a [Map]. If the asset cannot be loaded + /// or parsed, an empty map is returned and the error is logged. + /// + /// ## Returns + /// + /// A [Future>] containing the parsed JSON data. + /// Returns an empty map if loading or parsing fails. + /// + /// ## Example + /// + /// ```dart + /// final assetHandler = ExternalAssetHandler( + /// assetPath: 'assets/policies/config.json', + /// ); + /// + /// final policies = await assetHandler.loadAssets(); + /// if (policies.isNotEmpty) { + /// // Process the loaded policies + /// print('Loaded ${policies.length} policies'); + /// } else { + /// print('No policies loaded or asset not found'); + /// } + /// ``` + /// + /// ## Error Handling + /// + /// The method catches and logs the following types of errors: + /// * Asset not found errors + /// * JSON parsing errors + /// * File system access errors + /// + /// All errors are logged using [LogHandler.error] for debugging purposes. + Future> loadAssets() async { + try { + final jsonString = await rootBundle.loadString(_assetPath); + return JsonHandler.parseJsonString(jsonString); + } catch (e) { + LogHandler.error( + 'Failed to load policies from asset: $_assetPath', + error: e, + ); + // Return an empty map with the correct type to satisfy the return type + return {}; + } + } +} From 26995d4d0ee0ce3ecf629f7f866fa20231191bea Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Sun, 27 Jul 2025 22:28:32 +0200 Subject: [PATCH 04/21] feat(policy_manager)!: add initializeFromJsonAssets method - Loading policies from JSON assets with error handling --- lib/src/core/policy_manager.dart | 90 +++++++ test/core/policy_manager_test.dart | 387 +++++++++++++++++++++++++++++ 2 files changed, 477 insertions(+) diff --git a/lib/src/core/policy_manager.dart b/lib/src/core/policy_manager.dart index be084ef..6fd7f72 100644 --- a/lib/src/core/policy_manager.dart +++ b/lib/src/core/policy_manager.dart @@ -4,6 +4,7 @@ import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart' import 'package:flutter_policy_engine/src/core/memory_policy_storage.dart'; import 'package:flutter_policy_engine/src/core/role_evaluator.dart'; import 'package:flutter_policy_engine/src/models/role.dart'; +import 'package:flutter_policy_engine/src/utils/external_asset_handler.dart'; import 'package:flutter_policy_engine/src/utils/json_handler.dart'; import 'package:flutter_policy_engine/src/utils/log_handler.dart'; @@ -175,6 +176,95 @@ class PolicyManager extends ChangeNotifier { } } + /// Initializes the policy manager with policy data from a JSON asset file. + /// + /// Loads and parses JSON policy data from the specified [assetPath] and + /// initializes the policy manager with the loaded policies. This method + /// provides a convenient way to load policies from external asset files + /// bundled with the Flutter application. + /// + /// ## Parameters + /// + /// * [assetPath] - The path to the JSON asset file relative to the assets + /// directory (e.g., 'assets/policies/config.json'). Must be declared in + /// the `pubspec.yaml` file under the `assets` section. + /// + /// ## Usage + /// + /// ```dart + /// final policyManager = PolicyManager(); + /// + /// // Load policies from an asset file + /// await policyManager.initializeFromJsonAssets('assets/policies/user_roles.json'); + /// + /// // Check if initialization was successful + /// if (policyManager.isInitialized) { + /// print('Policy manager initialized successfully'); + /// } + /// ``` + /// + /// ## Asset File Format + /// + /// The JSON asset file should contain a map where keys are role identifiers + /// and values are JSON representations of [Role] objects: + /// + /// ```json + /// { + /// "admin": { + /// "name": "admin", + /// "permissions": ["read", "write", "delete"], + /// "content": ["all"] + /// }, + /// "user": { + /// "name": "user", + /// "permissions": ["read"], + /// "content": ["public", "user_content"] + /// } + /// } + /// ``` + /// + /// ## Error Handling + /// + /// This method handles errors gracefully by: + /// * Validating the asset path is not empty + /// * Catching and logging asset loading errors + /// * Catching and logging JSON parsing errors + /// * Catching and logging policy initialization errors + /// + /// If any error occurs during the process, it is logged using [LogHandler.error] + /// with detailed context information for debugging. + /// + /// ## Throws + /// + /// * [ArgumentError] - If [assetPath] is empty or null + /// + /// ## Dependencies + /// + /// This method depends on: + /// * [ExternalAssetHandler] - For loading and parsing the asset file + /// * [initialize] - For processing the loaded policy data + /// * [LogHandler] - For error logging and debugging + /// + Future initializeFromJsonAssets(String assetPath) async { + if (assetPath.isEmpty) { + throw ArgumentError('Asset path cannot be empty'); + } + + try { + final assetHandler = ExternalAssetHandler(assetPath: assetPath); + final jsonPolicies = await assetHandler.loadAssets(); + await initialize(jsonPolicies); + } catch (e, stackTrace) { + LogHandler.error( + 'Failed to initialize policy manager from JSON assets', + error: e, + stackTrace: stackTrace, + context: {'asset_path': assetPath}, + operation: 'policy_manager_initialize_from_json_assets_error', + ); + } + } + /// Checks if the specified [role] has access to the given [content]. /// /// Returns `true` if the policy manager is initialized and the evaluator diff --git a/test/core/policy_manager_test.dart b/test/core/policy_manager_test.dart index d6e717c..2a2be65 100644 --- a/test/core/policy_manager_test.dart +++ b/test/core/policy_manager_test.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_policy_engine/src/core/policy_manager.dart'; import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dart'; @@ -447,6 +449,391 @@ void main() { }); }); + group('initializeFromJsonAssets', () { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + test('should initialize successfully with valid JSON asset', () async { + // Mock the rootBundle to return valid JSON + const validJson = ''' + { + "admin": ["read", "write", "delete"], + "user": ["read"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(validJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/test.json'); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(2)); + expect(policyManager.roles['admin'], isA()); + expect(policyManager.roles['user'], isA()); + expect(policyManager.roles['admin']!.allowedContent, + containsAll(['read', 'write', 'delete'])); + expect( + policyManager.roles['user']!.allowedContent, containsAll(['read'])); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle empty asset path', () async { + expect( + () => policyManager.initializeFromJsonAssets(''), + throwsA(isA()), + ); + }); + + test('should handle asset not found gracefully', () async { + // Test with a non-existent asset path + await policyManager + .initializeFromJsonAssets('assets/policies/nonexistent.json'); + + // Should handle gracefully and initialize with empty policies + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles, isEmpty); + }); + + test('should handle invalid JSON in asset gracefully', () async { + // Mock the rootBundle to return invalid JSON + const invalidJson = 'invalid json content'; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(invalidJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/invalid.json'); + + // Should handle gracefully and initialize with empty policies + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles, isEmpty); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle malformed policy data in JSON asset', () async { + // Mock the rootBundle to return JSON with malformed policy data + const malformedJson = ''' + { + "admin": "not_a_list", + "user": null, + "guest": ["read"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(malformedJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/malformed.json'); + + // Should initialize with only valid policies + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(1)); + expect(policyManager.roles['guest'], isNotNull); + expect(policyManager.roles['admin'], isNull); + expect(policyManager.roles['user'], isNull); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle empty JSON object in asset', () async { + // Mock the rootBundle to return empty JSON object + const emptyJson = '{}'; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(emptyJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/empty.json'); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles, isEmpty); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle single policy in JSON asset', () async { + // Mock the rootBundle to return JSON with single policy + const singlePolicyJson = ''' + { + "admin": ["read", "write"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(singlePolicyJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/single.json'); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(1)); + expect(policyManager.roles['admin']!.name, equals('admin')); + expect(policyManager.roles['admin']!.allowedContent, + containsAll(['read', 'write'])); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle large policy set in JSON asset', () async { + // Create a large JSON with many policies + final largeJson = {}; + for (int i = 0; i < 100; i++) { + largeJson['role_$i'] = ['read', 'write']; + } + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(jsonEncode(largeJson))).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/large.json'); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(100)); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle policies with empty content arrays', () async { + // Mock the rootBundle to return JSON with empty content arrays + const emptyContentJson = ''' + { + "admin": [], + "user": ["read"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(emptyContentJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/empty_content.json'); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(2)); + expect(policyManager.roles['admin']!.allowedContent, isEmpty); + expect( + policyManager.roles['user']!.allowedContent, containsAll(['read'])); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle policies with duplicate content items', () async { + // Mock the rootBundle to return JSON with duplicate content + const duplicateContentJson = ''' + { + "admin": ["read", "read", "write", "write"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(duplicateContentJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/duplicate.json'); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(1)); + expect(policyManager.roles['admin']!.allowedContent, + containsAll(['read', 'write'])); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should handle platform exception during asset loading', () async { + // Test with an invalid asset path that will cause platform exception + await policyManager + .initializeFromJsonAssets('invalid/path/with/special/chars/\\/'); + + // Should handle gracefully and initialize with empty policies + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles, isEmpty); + }); + + test('should handle concurrent initialization from assets', () async { + // Mock the rootBundle to return valid JSON + const validJson = ''' + { + "admin": ["read", "write"], + "user": ["read"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(validJson)).buffer); + }); + + // Start multiple initialization calls + final futures = [ + policyManager.initializeFromJsonAssets('assets/policies/test1.json'), + policyManager.initializeFromJsonAssets('assets/policies/test2.json'), + policyManager.initializeFromJsonAssets('assets/policies/test3.json'), + ]; + + await Future.wait(futures); + + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(2)); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test( + 'should notify listeners after successful initialization from assets', + () async { + // Mock the rootBundle to return valid JSON + const validJson = ''' + { + "admin": ["read", "write"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(validJson)).buffer); + }); + + bool listenerCalled = false; + policyManager.addListener(() { + listenerCalled = true; + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/test.json'); + + expect(listenerCalled, isTrue); + expect(policyManager.isInitialized, isTrue); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test( + 'should save policies to storage after successful initialization from assets', + () async { + // Create a fresh policy manager and storage for this test + final freshStorage = MockPolicyStorage(); + final freshEvaluator = MockPolicyEvaluator(); + final freshPolicyManager = PolicyManager( + storage: freshStorage, + evaluator: freshEvaluator, + ); + + // Mock the rootBundle to return valid JSON + const validJson = ''' + { + "admin": ["read", "write"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(validJson)).buffer); + }); + + await freshPolicyManager + .initializeFromJsonAssets('assets/policies/test.json'); + + // Check that the expected policy is present in storage + expect(freshStorage.storedPolicies['admin'], isA()); + expect(freshStorage.storedPolicies['admin']!.allowedContent, + containsAll(['read', 'write'])); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test( + 'should handle storage save errors during initialization from assets', + () async { + // Mock the rootBundle to return valid JSON + const validJson = ''' + { + "admin": ["read", "write"] + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(validJson)).buffer); + }); + + // Make storage throw on save + mockStorage.setShouldThrowOnSave(true); + + // The method catches exceptions and doesn't rethrow them + await policyManager + .initializeFromJsonAssets('assets/policies/test.json'); + + // Should handle gracefully and not initialize due to storage error + expect(policyManager.isInitialized, isFalse); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + tearDown(() { + // Clean up mock message handlers + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + }); + group('addRole', () { test('should add a new role successfully', () async { await policyManager.initialize({}); From 30d3a8bbbe058e9a0f98c2f1457cc22464e0e914 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Sun, 27 Jul 2025 22:34:08 +0200 Subject: [PATCH 05/21] refactor: enhance readability of policy content assertions - Updated assertions for allowed content in the 'admin' role to improve clarity. --- test/core/policy_manager_test.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/core/policy_manager_test.dart b/test/core/policy_manager_test.dart index 2a2be65..d7197c6 100644 --- a/test/core/policy_manager_test.dart +++ b/test/core/policy_manager_test.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_policy_engine/src/core/policy_manager.dart'; import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dart'; @@ -788,8 +787,12 @@ void main() { // Check that the expected policy is present in storage expect(freshStorage.storedPolicies['admin'], isA()); - expect(freshStorage.storedPolicies['admin']!.allowedContent, - containsAll(['read', 'write'])); + expect( + (freshStorage.storedPolicies['admin'] as Role).allowedContent, + containsAll( + ['read', 'write'], + ), + ); // Clean up mock message handler TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger From 7278732b3b243ea1e89b0bb8eea9feebb432ff0a Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 00:23:09 +0200 Subject: [PATCH 06/21] feat(policy_manager): enhance policy initialization with validation and logging --- lib/src/core/policy_manager.dart | 108 ++++++++++++++++++++++++++++- test/core/policy_manager_test.dart | 64 +++++++++++++---- 2 files changed, 157 insertions(+), 15 deletions(-) diff --git a/lib/src/core/policy_manager.dart b/lib/src/core/policy_manager.dart index 6fd7f72..3ba4ffe 100644 --- a/lib/src/core/policy_manager.dart +++ b/lib/src/core/policy_manager.dart @@ -253,7 +253,113 @@ class PolicyManager extends ChangeNotifier { try { final assetHandler = ExternalAssetHandler(assetPath: assetPath); final jsonPolicies = await assetHandler.loadAssets(); - await initialize(jsonPolicies); + + try { + LogHandler.info( + 'Initializing policy manager with assets', + context: { + 'policy_count': jsonPolicies.length, + 'policy_keys': jsonPolicies.keys.take(5).toList(), + }, + operation: 'policy_manager_initialize', + ); + + // Create a map of valid policies, skipping invalid ones + final validPolicies = >{}; + + for (final entry in jsonPolicies.entries) { + final key = entry.key; + final value = entry.value; + + if (value == null) { + LogHandler.warning( + 'Skipping null policy value', + context: {'role': key}, + operation: 'policy_validation_skip', + ); + continue; + } + + if (value is! Map) { + LogHandler.warning( + 'Skipping invalid policy value type', + context: { + 'role': key, + 'expected_type': 'Map', + 'actual_type': value.runtimeType.toString(), + }, + operation: 'policy_validation_skip', + ); + continue; + } + + // Add key as role name to value if not already present + if (!value.containsKey('roleName')) { + value['roleName'] = key; + } + + try { + // Create the policy and add to valid policies + final role = Role.fromJson(value); + validPolicies[key] = role.toJson(); + } catch (e) { + LogHandler.warning( + 'Skipping policy with invalid structure', + context: {'role': key, 'error': e.toString()}, + operation: 'policy_validation_skip', + ); + continue; + } + } + + _roles = JsonHandler.parseMap( + validPolicies, + (json) => Role.fromJson(json), + context: 'policy_manager', + allowPartialSuccess: true, + ); + + // Only create evaluator if we have at least some policies + if (_roles.isNotEmpty) { + _evaluator = RoleEvaluator(_roles); + await _storage.savePolicies(_roles); + _isInitialized = true; + + LogHandler.info( + 'Policy manager initialized successfully', + context: { + 'loaded_policies': _roles.length, + 'total_policies': jsonPolicies.length, + }, + operation: 'policy_manager_initialized', + ); + } else { + LogHandler.warning( + 'Policy manager initialized with no valid policies', + context: { + 'total_policies': jsonPolicies.length, + }, + operation: 'policy_manager_empty', + ); + // Still mark as initialized but with empty policies + _isInitialized = true; + } + + notifyListeners(); + } catch (e, stackTrace) { + LogHandler.error( + 'Failed to initialize policy manager', + error: e, + stackTrace: stackTrace, + context: { + 'policy_count': jsonPolicies.length, + }, + operation: 'policy_manager_initialize_error', + ); + + // Re-throw to allow caller to handle the error + rethrow; + } } catch (e, stackTrace) { LogHandler.error( 'Failed to initialize policy manager from JSON assets', diff --git a/test/core/policy_manager_test.dart b/test/core/policy_manager_test.dart index d7197c6..9d24322 100644 --- a/test/core/policy_manager_test.dart +++ b/test/core/policy_manager_test.dart @@ -457,8 +457,12 @@ void main() { // Mock the rootBundle to return valid JSON const validJson = ''' { - "admin": ["read", "write", "delete"], - "user": ["read"] + "admin": { + "allowedContent": ["read", "write", "delete"] + }, + "user": { + "allowedContent": ["read"] + } } '''; @@ -471,6 +475,7 @@ void main() { await policyManager .initializeFromJsonAssets('assets/policies/test.json'); + // The method should initialize successfully with valid JSON expect(policyManager.isInitialized, isTrue); expect(policyManager.roles.length, equals(2)); expect(policyManager.roles['admin'], isA()); @@ -528,9 +533,16 @@ void main() { // Mock the rootBundle to return JSON with malformed policy data const malformedJson = ''' { - "admin": "not_a_list", + "admin": { + "allowedContent": "not_a_list" + }, "user": null, - "guest": ["read"] + "guest": { + "allowedContent": ["read"] + }, + "invalid_role": { + "allowedContent": [123, "read"] + } } '''; @@ -547,8 +559,11 @@ void main() { expect(policyManager.isInitialized, isTrue); expect(policyManager.roles.length, equals(1)); expect(policyManager.roles['guest'], isNotNull); + expect(policyManager.roles['guest']!.name, equals('guest')); + expect(policyManager.roles['guest']!.allowedContent, contains('read')); expect(policyManager.roles['admin'], isNull); expect(policyManager.roles['user'], isNull); + expect(policyManager.roles['invalid_role'], isNull); // Clean up mock message handler TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -580,7 +595,9 @@ void main() { // Mock the rootBundle to return JSON with single policy const singlePolicyJson = ''' { - "admin": ["read", "write"] + "admin": { + "allowedContent": ["read", "write"] + } } '''; @@ -608,7 +625,9 @@ void main() { // Create a large JSON with many policies final largeJson = {}; for (int i = 0; i < 100; i++) { - largeJson['role_$i'] = ['read', 'write']; + largeJson['role_$i'] = { + 'allowedContent': ['read', 'write'] + }; } TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -632,8 +651,12 @@ void main() { // Mock the rootBundle to return JSON with empty content arrays const emptyContentJson = ''' { - "admin": [], - "user": ["read"] + "admin": { + "allowedContent": [] + }, + "user": { + "allowedContent": ["read"] + } } '''; @@ -661,7 +684,9 @@ void main() { // Mock the rootBundle to return JSON with duplicate content const duplicateContentJson = ''' { - "admin": ["read", "read", "write", "write"] + "admin": { + "allowedContent": ["read", "read", "write", "write"] + } } '''; @@ -698,8 +723,12 @@ void main() { // Mock the rootBundle to return valid JSON const validJson = ''' { - "admin": ["read", "write"], - "user": ["read"] + "admin": { + "allowedContent": ["read", "write"] + }, + "user": { + "allowedContent": ["read"] + } } '''; @@ -732,7 +761,9 @@ void main() { // Mock the rootBundle to return valid JSON const validJson = ''' { - "admin": ["read", "write"] + "admin": { + "allowedContent": ["read", "write"] + } } '''; @@ -772,7 +803,9 @@ void main() { // Mock the rootBundle to return valid JSON const validJson = ''' { - "admin": ["read", "write"] + "admin": { + "allowedContent": ["read", "write"] + } } '''; @@ -786,6 +819,7 @@ void main() { .initializeFromJsonAssets('assets/policies/test.json'); // Check that the expected policy is present in storage + expect(freshPolicyManager.isInitialized, isTrue); expect(freshStorage.storedPolicies['admin'], isA()); expect( (freshStorage.storedPolicies['admin'] as Role).allowedContent, @@ -805,7 +839,9 @@ void main() { // Mock the rootBundle to return valid JSON const validJson = ''' { - "admin": ["read", "write"] + "admin": { + "allowedContent": ["read", "write"] + } } '''; From e933a657c5a83f6f048b539aceb5ddacc1b7e507 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 00:48:45 +0200 Subject: [PATCH 07/21] feat: enhance asset path validation and add comprehensive unit tests --- lib/src/utils/external_asset_handler.dart | 2 +- test/utils/external_asset_handler_test.dart | 430 ++++++++++++++++++++ 2 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 test/utils/external_asset_handler_test.dart diff --git a/lib/src/utils/external_asset_handler.dart b/lib/src/utils/external_asset_handler.dart index 9dbae80..3251b2b 100644 --- a/lib/src/utils/external_asset_handler.dart +++ b/lib/src/utils/external_asset_handler.dart @@ -49,7 +49,7 @@ class ExternalAssetHandler { ExternalAssetHandler({ required String assetPath, }) : _assetPath = assetPath { - if (assetPath.isEmpty) { + if (assetPath.trim().isEmpty) { throw ArgumentError('Asset path cannot be empty', 'assetPath'); } } diff --git a/test/utils/external_asset_handler_test.dart b/test/utils/external_asset_handler_test.dart new file mode 100644 index 0000000..99ad362 --- /dev/null +++ b/test/utils/external_asset_handler_test.dart @@ -0,0 +1,430 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_policy_engine/src/utils/external_asset_handler.dart'; +import 'package:flutter_policy_engine/src/utils/json_handler.dart'; +import 'package:flutter_policy_engine/src/utils/log_handler.dart'; + +void main() { + group('ExternalAssetHandler', () { + setUp(() { + // Reset LogHandler to default state before each test + LogHandler.reset(); + }); + + group('Constructor', () { + test('should create instance with valid asset path', () { + const assetPath = 'assets/policies/config.json'; + + expect(() { + ExternalAssetHandler(assetPath: assetPath); + }, returnsNormally); + }); + + test('should throw ArgumentError for empty asset path', () { + expect( + () => ExternalAssetHandler(assetPath: ''), + throwsA(isA()), + ); + }); + + test('should throw ArgumentError for whitespace-only asset path', () { + expect( + () => ExternalAssetHandler(assetPath: ' '), + throwsA(isA()), + ); + }); + + test('should accept asset path with special characters', () { + const assetPath = 'assets/policies/user-roles_v2.json'; + + expect(() { + ExternalAssetHandler(assetPath: assetPath); + }, returnsNormally); + }); + + test('should accept asset path with nested directories', () { + const assetPath = 'assets/policies/production/user_roles.json'; + + expect(() { + ExternalAssetHandler(assetPath: assetPath); + }, returnsNormally); + }); + + test('should accept asset path with different file extensions', () { + const assetPath = 'assets/policies/config.json'; + + expect(() { + ExternalAssetHandler(assetPath: assetPath); + }, returnsNormally); + }); + }); + + group('loadAssets', () { + test('should handle non-existent asset gracefully', () async { + const assetPath = 'assets/policies/valid_config.json'; + + // Initialize Flutter test binding + TestWidgetsFlutterBinding.ensureInitialized(); + + // Create handler with non-existent asset path + // This will test the error handling path since the asset doesn't exist in test environment + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + // The result should be empty because the asset doesn't exist + expect(result, isA>()); + expect(result, isEmpty); + }); + + group('Error handling', () { + test('should return empty map when asset not found', () async { + const assetPath = 'assets/policies/nonexistent.json'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + throw PlatformException( + code: 'ASSET_NOT_FOUND', + message: 'Asset not found: $assetPath', + ); + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test('should return empty map when JSON parsing fails', () async { + const assetPath = 'assets/policies/invalid_json.json'; + const invalidJson = '{"invalid": json, "missing": quotes}'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return invalidJson; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test('should return empty map when asset is empty string', () async { + const assetPath = 'assets/policies/empty_string.json'; + const emptyContent = ''; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return emptyContent; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test('should return empty map when asset contains only whitespace', + () async { + const assetPath = 'assets/policies/whitespace_only.json'; + const whitespaceContent = ' \n\t '; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return whitespaceContent; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test( + 'should return empty map when asset contains JSON array instead of object', + () async { + const assetPath = 'assets/policies/array_instead_of_object.json'; + const arrayJson = '[1, 2, 3, 4, 5]'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return arrayJson; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test( + 'should return empty map when asset contains JSON primitive instead of object', + () async { + const assetPath = 'assets/policies/primitive_instead_of_object.json'; + const primitiveJson = '"hello world"'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return primitiveJson; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test('should return empty map when platform exception occurs', + () async { + const assetPath = 'assets/policies/platform_error.json'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + throw PlatformException( + code: 'UNKNOWN_ERROR', + message: 'Unknown platform error', + ); + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test('should return empty map when general exception occurs', () async { + const assetPath = 'assets/policies/general_error.json'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + throw Exception('General error occurred'); + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + }); + }); + + group('Integration with JsonHandler', () { + test('should handle JSON that JsonHandler.parseJsonString would reject', + () async { + const assetPath = 'assets/policies/invalid_integration.json'; + const invalidJson = '{"invalid": json, "syntax": error}'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return invalidJson; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + // Should return empty map instead of throwing + expect(result, isA>()); + expect(result, isEmpty); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + }); + + group('Logging behavior', () { + test('should log error when asset loading fails', () async { + const assetPath = 'assets/policies/logging_test.json'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + throw PlatformException( + code: 'ASSET_NOT_FOUND', + message: 'Asset not found: $assetPath', + ); + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isEmpty); + // Note: We can't easily test the actual logging output in unit tests + // but we can verify the method completes without throwing + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + + test('should log error when JSON parsing fails', () async { + const assetPath = 'assets/policies/logging_parse_test.json'; + const invalidJson = '{"invalid": json, "missing": quotes}'; + + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + (MethodCall methodCall) async { + if (methodCall.method == 'loadString' && + methodCall.arguments == assetPath) { + return invalidJson; + } + return null; + }, + ); + + final handler = ExternalAssetHandler(assetPath: assetPath); + final result = await handler.loadAssets(); + + expect(result, isEmpty); + // Method should complete without throwing, logging the error internally + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/services'), + null, + ); + }); + }); + }); +} From e95a8d154fbbb32b26ec8c30308b7d3d2e5a0028 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 00:53:12 +0200 Subject: [PATCH 08/21] chore(tests): remove unused import from external_asset_handler_test.dart --- test/utils/external_asset_handler_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/test/utils/external_asset_handler_test.dart b/test/utils/external_asset_handler_test.dart index 99ad362..85564d3 100644 --- a/test/utils/external_asset_handler_test.dart +++ b/test/utils/external_asset_handler_test.dart @@ -1,7 +1,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_policy_engine/src/utils/external_asset_handler.dart'; -import 'package:flutter_policy_engine/src/utils/json_handler.dart'; import 'package:flutter_policy_engine/src/utils/log_handler.dart'; void main() { From 2c96f16f318dfcd82c17ed472deb3b9361245933 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 17:39:27 +0200 Subject: [PATCH 09/21] feat(policy_manager): add tests for handling non-Map policy values in JSON assets --- test/core/policy_manager_test.dart | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/core/policy_manager_test.dart b/test/core/policy_manager_test.dart index 9d24322..14f7d7e 100644 --- a/test/core/policy_manager_test.dart +++ b/test/core/policy_manager_test.dart @@ -570,6 +570,82 @@ void main() { .setMockMessageHandler('flutter/assets', null); }); + test('should handle non-Map policy values in JSON asset', () async { + // Mock the rootBundle to return JSON with non-Map policy values + const nonMapJson = ''' + { + "admin": "not_a_map", + "user": 123, + "guest": ["read", "write"], + "valid_role": { + "allowedContent": ["read"] + } + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(nonMapJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/non_map.json'); + + // Should initialize with only valid policies + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(1)); + expect(policyManager.roles['valid_role'], isNotNull); + expect(policyManager.roles['valid_role']!.name, equals('valid_role')); + expect(policyManager.roles['valid_role']!.allowedContent, + contains('read')); + expect(policyManager.roles['admin'], isNull); + expect(policyManager.roles['user'], isNull); + expect(policyManager.roles['guest'], isNull); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('should execute warning log for non-Map values', () async { + // Mock the rootBundle to return JSON with non-Map policy values + const nonMapJson = ''' + { + "admin": "not_a_map", + "user": 123, + "guest": ["read", "write"], + "valid_role": { + "allowedContent": ["read"] + } + } + '''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + return ByteData.view( + Uint8List.fromList(utf8.encode(nonMapJson)).buffer); + }); + + await policyManager + .initializeFromJsonAssets('assets/policies/non_map.json'); + + // Should initialize with only valid policies + expect(policyManager.isInitialized, isTrue); + expect(policyManager.roles.length, equals(1)); + expect(policyManager.roles['valid_role'], isNotNull); + expect(policyManager.roles['valid_role']!.name, equals('valid_role')); + expect(policyManager.roles['valid_role']!.allowedContent, + contains('read')); + expect(policyManager.roles['admin'], isNull); + expect(policyManager.roles['user'], isNull); + expect(policyManager.roles['guest'], isNull); + + // Clean up mock message handler + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + test('should handle empty JSON object in asset', () async { // Mock the rootBundle to return empty JSON object const emptyJson = '{}'; From 516604462cb22317ed67d7e00c03e37d3650fbbb Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 17:43:39 +0200 Subject: [PATCH 10/21] feat(json_assets_demo): add JSON Assets Demo for policy management --- example/JSON_ASSETS_DEMO.md | 140 ++++++++++ example/assets/policies/user_roles.json | 43 +++ example/lib/json_assets_demo.dart | 343 ++++++++++++++++++++++++ example/lib/main.dart | 19 ++ example/pubspec.yaml | 3 + 5 files changed, 548 insertions(+) create mode 100644 example/JSON_ASSETS_DEMO.md create mode 100644 example/assets/policies/user_roles.json create mode 100644 example/lib/json_assets_demo.dart diff --git a/example/JSON_ASSETS_DEMO.md b/example/JSON_ASSETS_DEMO.md new file mode 100644 index 0000000..314c7e5 --- /dev/null +++ b/example/JSON_ASSETS_DEMO.md @@ -0,0 +1,140 @@ +# JSON Assets Demo + +This demo showcases the `initializeFromJsonAssets` method of the Flutter Policy Engine, demonstrating how to load policy configurations from JSON asset files bundled with your Flutter application. + +## Overview + +The JSON Assets Demo provides an interactive interface to: + +- Initialize the policy manager from a JSON asset file +- Test role-based permissions +- Test content access control +- View detailed role information + +## Features + +### 1. Asset-based Policy Loading + +- Loads policies from `assets/policies/user_roles.json` +- Demonstrates the `initializeFromJsonAssets` method +- Shows real-time initialization status + +### 2. Interactive Testing + +- **Role Selection**: Choose from predefined roles (admin, manager, editor, viewer, guest) +- **Permission Testing**: Test if a role has specific permissions +- **Content Access Testing**: Verify content access using the `hasAccess` method +- **Role Information**: View detailed role configuration + +### 3. Real-time Feedback + +- Visual status indicators +- Detailed error messages +- Test results with clear success/failure indicators + +## JSON Asset Format + +The demo uses a JSON file with the following structure: + +```json +{ + "admin": { + "allowedContent": [ + "read", + "write", + "delete", + "manage_users", + "system_config", + "all", + "admin_panel", + "user_management", + "system_settings" + ] + }, + "manager": { + "allowedContent": [ + "read", + "write", + "manage_team", + "team_content", + "reports", + "analytics", + "public" + ] + } +} +``` + +### Required Fields + +- `allowedContent`: Array of strings representing permissions and content access + +## Usage + +1. **Launch the Demo**: Select "JSON Assets Demo" from the main menu +2. **Initialize**: The policy manager automatically initializes from the JSON asset +3. **Test Permissions**: Select a role and permission to test +4. **Test Content Access**: Use the `hasAccess` method to verify content access +5. **View Role Details**: Get comprehensive information about any role + +## Implementation Details + +### Asset Configuration + +The JSON file is declared in `pubspec.yaml`: + +```yaml +flutter: + assets: + - assets/policies/ +``` + +### Policy Manager Initialization + +```dart +final policyManager = PolicyManager(); +await policyManager.initializeFromJsonAssets('assets/policies/user_roles.json'); +``` + +### Permission Testing + +```dart +final role = policyManager.roles[roleName]; +final hasPermission = role?.allowedContent.contains(permission) ?? false; +``` + +### Content Access Testing + +```dart +final hasAccess = policyManager.hasAccess(roleName, content); +``` + +## Available Roles + +| Role | Permissions | Content Access | +| ------- | ------------------------------------------------ | --------------------------------------------------- | +| admin | read, write, delete, manage_users, system_config | all, admin_panel, user_management, system_settings | +| manager | read, write, manage_team | team_content, reports, analytics, public | +| editor | read, write, publish | content_creation, drafts, published_content, public | +| viewer | read | public, published_content, reports | +| guest | read | public | + +## Error Handling + +The demo includes comprehensive error handling: + +- Asset loading failures +- JSON parsing errors +- Policy initialization errors +- Role not found scenarios +- Permission/content access validation + +## Benefits + +1. **External Configuration**: Policies can be updated without code changes +2. **Asset Bundling**: JSON files are bundled with the app for offline access +3. **Flexible Structure**: Easy to modify role permissions and content access +4. **Runtime Testing**: Interactive testing of policy configurations +5. **Clear Feedback**: Visual indicators and detailed error messages + +This demo demonstrates how to effectively use the `initializeFromJsonAssets` method for flexible, asset-based policy management in Flutter applications. diff --git a/example/assets/policies/user_roles.json b/example/assets/policies/user_roles.json new file mode 100644 index 0000000..ab7fa13 --- /dev/null +++ b/example/assets/policies/user_roles.json @@ -0,0 +1,43 @@ +{ + "admin": { + "allowedContent": [ + "read", + "write", + "delete", + "manage_users", + "system_config", + "all", + "admin_panel", + "user_management", + "system_settings" + ] + }, + "manager": { + "allowedContent": [ + "read", + "write", + "manage_team", + "team_content", + "reports", + "analytics", + "public" + ] + }, + "editor": { + "allowedContent": [ + "read", + "write", + "publish", + "content_creation", + "drafts", + "published_content", + "public" + ] + }, + "viewer": { + "allowedContent": ["read", "public", "published_content", "reports"] + }, + "guest": { + "allowedContent": ["read", "public"] + } +} diff --git a/example/lib/json_assets_demo.dart b/example/lib/json_assets_demo.dart new file mode 100644 index 0000000..73eaa62 --- /dev/null +++ b/example/lib/json_assets_demo.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_policy_engine/flutter_policy_engine.dart'; + +class JsonAssetsDemo extends StatefulWidget { + const JsonAssetsDemo({super.key}); + + @override + State createState() => _JsonAssetsDemoState(); +} + +class _JsonAssetsDemoState extends State { + final PolicyManager _policyManager = PolicyManager(); + bool _isInitialized = false; + bool _isLoading = false; + String _selectedRole = 'admin'; + String _selectedPermission = 'read'; + String _lastResult = ''; + String _errorMessage = ''; + + final List _availableRoles = [ + 'admin', + 'manager', + 'editor', + 'viewer', + 'guest', + ]; + + final List _availablePermissions = [ + 'read', + 'write', + 'delete', + 'manage_users', + 'system_config', + 'manage_team', + 'publish', + ]; + + @override + void initState() { + super.initState(); + _initializePolicyManager(); + } + + Future _initializePolicyManager() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + await _policyManager + .initializeFromJsonAssets('assets/policies/user_roles.json'); + + setState(() { + _isInitialized = _policyManager.isInitialized; + _isLoading = false; + _lastResult = _isInitialized + ? '✅ Policy manager initialized successfully from JSON assets!' + : '❌ Failed to initialize policy manager'; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Error: $e'; + _lastResult = '❌ Initialization failed'; + }); + } + } + + void _testPermission() { + if (!_isInitialized) { + setState(() { + _lastResult = '❌ Policy manager not initialized'; + }); + return; + } + + try { + final role = _policyManager.roles[_selectedRole]; + if (role != null) { + final hasPermission = role.allowedContent.contains(_selectedPermission); + setState(() { + _lastResult = hasPermission + ? '✅ Role "$_selectedRole" has permission "$_selectedPermission"' + : '❌ Role "$_selectedRole" does NOT have permission "$_selectedPermission"'; + }); + } else { + setState(() { + _lastResult = '❌ Role "$_selectedRole" not found'; + }); + } + } catch (e) { + setState(() { + _lastResult = '❌ Error testing permission: $e'; + }); + } + } + + void _getRoleInfo() { + if (!_isInitialized) { + setState(() { + _lastResult = '❌ Policy manager not initialized'; + }); + return; + } + + try { + final role = _policyManager.roles[_selectedRole]; + if (role != null) { + setState(() { + _lastResult = ''' +✅ Role Information for "$_selectedRole": + Name: ${role.name} + Allowed Content: ${role.allowedContent.join(', ')} +'''; + }); + } else { + setState(() { + _lastResult = '❌ Role "$_selectedRole" not found'; + }); + } + } catch (e) { + setState(() { + _lastResult = '❌ Error getting role info: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('JSON Assets Demo'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Initialization Status Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _isInitialized ? Icons.check_circle : Icons.error, + color: _isInitialized ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text( + 'Policy Manager Status', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + if (_isLoading) + const LinearProgressIndicator() + else + Text( + _isInitialized + ? '✅ Initialized from JSON assets' + : '❌ Not initialized', + style: Theme.of(context).textTheme.bodyMedium, + ), + if (_errorMessage.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _errorMessage, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.red, + ), + ), + ], + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isLoading ? null : _initializePolicyManager, + icon: const Icon(Icons.refresh), + label: const Text('Reinitialize'), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Role Selection Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Role Selection', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedRole, + decoration: const InputDecoration( + labelText: 'Select Role', + border: OutlineInputBorder(), + ), + items: _availableRoles.map((role) { + return DropdownMenuItem( + value: role, + child: Text(role), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedRole = value; + }); + } + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Permission Testing Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Permission Testing', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedPermission, + decoration: const InputDecoration( + labelText: 'Select Permission', + border: OutlineInputBorder(), + ), + items: _availablePermissions.map((permission) { + return DropdownMenuItem( + value: permission, + child: Text(permission), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedPermission = value; + }); + } + }, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isInitialized ? _testPermission : null, + icon: const Icon(Icons.security), + label: const Text('Test Permission'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Role Information Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Role Information', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isInitialized ? _getRoleInfo : null, + icon: const Icon(Icons.info), + label: const Text('Get Role Details'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Results Card + if (_lastResult.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Test Results', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Text( + _lastResult, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index f6e03cd..9b37534 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_policy_engine_example/policy_engine_demo.dart'; import 'package:flutter_policy_engine_example/role_management_demo.dart'; +import 'package:flutter_policy_engine_example/json_assets_demo.dart'; void main() { runApp(const MyApp()); @@ -98,6 +99,24 @@ class HomeScreen extends StatelessWidget { foregroundColor: Colors.white, ), ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const JsonAssetsDemo(), + ), + ); + }, + icon: const Icon(Icons.file_copy), + label: const Text('JSON Assets Demo'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + backgroundColor: Colors.teal, + foregroundColor: Colors.white, + ), + ), ], ), ), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a329c11..3747ba6 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,3 +23,6 @@ dev_dependencies: flutter: uses-material-design: true + + assets: + - assets/policies/ From f1c131def7ae2568c1b89df86762fb49f6703f64 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:01:16 +0200 Subject: [PATCH 11/21] docs: add JSON Assets Demo example to showcase policy loading from external JSON files --- docs.json | 5 + docs/examples/json-assets-demo.mdx | 546 +++++++++++++++++++++++++++++ 2 files changed, 551 insertions(+) create mode 100644 docs/examples/json-assets-demo.mdx diff --git a/docs.json b/docs.json index 8b6c271..8abd17a 100644 --- a/docs.json +++ b/docs.json @@ -40,6 +40,11 @@ "href": "/examples/basic-policy-demo", "icon": "code" }, + { + "title": "JSON Assets Demo", + "href": "/examples/json-assets-demo", + "icon": "file-text" + }, { "title": "Role Management Demo", "href": "/examples/role-management-demo", diff --git a/docs/examples/json-assets-demo.mdx b/docs/examples/json-assets-demo.mdx new file mode 100644 index 0000000..ecc79d4 --- /dev/null +++ b/docs/examples/json-assets-demo.mdx @@ -0,0 +1,546 @@ +--- +title: JSON Assets Demo +description: Loading policies from external JSON asset files +--- + +# JSON Assets Demo + +This example demonstrates how to load and manage policies from external JSON asset files using the `initializeFromJsonAssets` method. This approach is ideal for applications that need to bundle policy configurations with the app or load them from external sources. + +## 🎯 Demo Overview + +The JSON Assets Demo showcases: + +- **External Policy Loading**: Loading policies from JSON files bundled as assets +- **Dynamic Role Management**: Interactive role selection and permission testing +- **Real-time Validation**: Testing permissions against loaded policies +- **Error Handling**: Graceful handling of initialization failures +- **Visual Feedback**: Clear status indicators and result displays + +## 📁 Asset File Structure + +The demo uses a JSON asset file located at `assets/policies/user_roles.json` with the following structure: + +```json +{ + "admin": { + "allowedContent": [ + "read", + "write", + "delete", + "manage_users", + "system_config", + "all", + "admin_panel", + "user_management", + "system_settings" + ] + }, + "manager": { + "allowedContent": [ + "read", + "write", + "manage_team", + "team_content", + "reports", + "analytics", + "public" + ] + }, + "editor": { + "allowedContent": [ + "read", + "write", + "publish", + "content_creation", + "drafts", + "published_content", + "public" + ] + }, + "viewer": { + "allowedContent": ["read", "public", "published_content", "reports"] + }, + "guest": { + "allowedContent": ["read", "public"] + } +} +``` + +## 🚀 Complete Demo Implementation + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_policy_engine/flutter_policy_engine.dart'; + +class JsonAssetsDemo extends StatefulWidget { + const JsonAssetsDemo({super.key}); + + @override + State createState() => _JsonAssetsDemoState(); +} + +class _JsonAssetsDemoState extends State { + final PolicyManager _policyManager = PolicyManager(); + bool _isInitialized = false; + bool _isLoading = false; + String _selectedRole = 'admin'; + String _selectedPermission = 'read'; + String _lastResult = ''; + String _errorMessage = ''; + + final List _availableRoles = [ + 'admin', + 'manager', + 'editor', + 'viewer', + 'guest', + ]; + + final List _availablePermissions = [ + 'read', + 'write', + 'delete', + 'manage_users', + 'system_config', + 'manage_team', + 'publish', + ]; + + @override + void initState() { + super.initState(); + _initializePolicyManager(); + } + + Future _initializePolicyManager() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + await _policyManager + .initializeFromJsonAssets('assets/policies/user_roles.json'); + + setState(() { + _isInitialized = _policyManager.isInitialized; + _isLoading = false; + _lastResult = _isInitialized + ? '✅ Policy manager initialized successfully from JSON assets!' + : '❌ Failed to initialize policy manager'; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Error: $e'; + _lastResult = '❌ Initialization failed'; + }); + } + } + + void _testPermission() { + if (!_isInitialized) { + setState(() { + _lastResult = '❌ Policy manager not initialized'; + }); + return; + } + + try { + final role = _policyManager.roles[_selectedRole]; + if (role != null) { + final hasPermission = role.allowedContent.contains(_selectedPermission); + setState(() { + _lastResult = hasPermission + ? '✅ Role "$_selectedRole" has permission "$_selectedPermission"' + : '❌ Role "$_selectedRole" does NOT have permission "$_selectedPermission"'; + }); + } else { + setState(() { + _lastResult = '❌ Role "$_selectedRole" not found'; + }); + } + } catch (e) { + setState(() { + _lastResult = '❌ Error testing permission: $e'; + }); + } + } + + void _getRoleInfo() { + if (!_isInitialized) { + setState(() { + _lastResult = '❌ Policy manager not initialized'; + }); + return; + } + + try { + final role = _policyManager.roles[_selectedRole]; + if (role != null) { + setState(() { + _lastResult = ''' +✅ Role Information for "$_selectedRole": + Name: ${role.name} + Allowed Content: ${role.allowedContent.join(', ')} +'''; + }); + } else { + setState(() { + _lastResult = '❌ Role "$_selectedRole" not found'; + }); + } + } catch (e) { + setState(() { + _lastResult = '❌ Error getting role info: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('JSON Assets Demo'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Initialization Status Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _isInitialized ? Icons.check_circle : Icons.error, + color: _isInitialized ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text( + 'Policy Manager Status', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + if (_isLoading) + const LinearProgressIndicator() + else + Text( + _isInitialized + ? '✅ Initialized from JSON assets' + : '❌ Not initialized', + style: Theme.of(context).textTheme.bodyMedium, + ), + if (_errorMessage.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _errorMessage, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.red, + ), + ), + ], + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isLoading ? null : _initializePolicyManager, + icon: const Icon(Icons.refresh), + label: const Text('Reinitialize'), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Role Selection Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Role Selection', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedRole, + decoration: const InputDecoration( + labelText: 'Select Role', + border: OutlineInputBorder(), + ), + items: _availableRoles.map((role) { + return DropdownMenuItem( + value: role, + child: Text(role), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedRole = value; + }); + } + }, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Permission Testing Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Permission Testing', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedPermission, + decoration: const InputDecoration( + labelText: 'Select Permission', + border: OutlineInputBorder(), + ), + items: _availablePermissions.map((permission) { + return DropdownMenuItem( + value: permission, + child: Text(permission), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedPermission = value; + }); + } + }, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isInitialized ? _testPermission : null, + icon: const Icon(Icons.security), + label: const Text('Test Permission'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Role Information Card + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Role Information', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _isInitialized ? _getRoleInfo : null, + icon: const Icon(Icons.info), + label: const Text('Get Role Details'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Results Card + if (_lastResult.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Test Results', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Text( + _lastResult, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} +``` + +## 🔧 Setup Requirements + +### 1. Asset Declaration + +Add the JSON asset file to your `pubspec.yaml`: + +```yaml +flutter: + assets: + - assets/policies/user_roles.json +``` + +### 2. File Structure + +Ensure your project has the following structure: + +``` +your_app/ +├── assets/ +│ └── policies/ +│ └── user_roles.json +├── lib/ +│ └── json_assets_demo.dart +└── pubspec.yaml +``` + +## 🎨 Key Features Explained + +### Initialization Process + +The demo initializes the policy manager using `initializeFromJsonAssets`: + +```dart +await _policyManager.initializeFromJsonAssets('assets/policies/user_roles.json'); +``` + +This method: + +- Loads the JSON file from assets +- Parses the JSON structure +- Creates `Role` objects from the parsed data +- Initializes the policy manager with the loaded policies + +### Error Handling + +The implementation includes comprehensive error handling: + +```dart +try { + await _policyManager.initializeFromJsonAssets('assets/policies/user_roles.json'); + setState(() { + _isInitialized = _policyManager.isInitialized; + _isLoading = false; + _lastResult = _isInitialized + ? '✅ Policy manager initialized successfully from JSON assets!' + : '❌ Failed to initialize policy manager'; + }); +} catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Error: $e'; + _lastResult = '❌ Initialization failed'; + }); +} +``` + +### Permission Testing + +The demo provides real-time permission testing: + +```dart +void _testPermission() { + if (!_isInitialized) return; + + final role = _policyManager.roles[_selectedRole]; + if (role != null) { + final hasPermission = role.allowedContent.contains(_selectedPermission); + // Update UI with result + } +} +``` + +## 🎯 Use Cases + +This approach is ideal for: + +- **Configuration Management**: Centralizing policy configurations in external files +- **Dynamic Updates**: Updating policies without code changes +- **Environment-Specific Policies**: Different policies for development, staging, and production +- **Localization**: Different policies for different regions or user groups +- **A/B Testing**: Testing different policy configurations + +## 🔍 Best Practices + +### 1. Asset Validation + +Always validate your JSON structure before deployment: + +```dart +// Validate JSON structure +try { + final testManager = PolicyManager(); + await testManager.initializeFromJsonAssets('assets/policies/user_roles.json'); + print('✅ JSON structure is valid'); +} catch (e) { + print('❌ JSON structure error: $e'); +} +``` + +### 2. Error Recovery + +Implement fallback mechanisms: + +```dart +Future initializeWithFallback() async { + try { + await _policyManager.initializeFromJsonAssets('assets/policies/user_roles.json'); + } catch (e) { + // Fallback to default policies + await _policyManager.initialize(defaultPolicies); + } +} +``` + +## 🚀 Next Steps + +- Explore [Role Management Demo](/examples/role-management-demo) for advanced role operations +- Learn about [Policy Management](/core-concepts/policy-management) concepts +- Check out the [Quick Start](/quick-start) guide for basic setup From cbc5472729c606443e8d31284f9a77e698123be7 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:23:01 +0200 Subject: [PATCH 12/21] refactor(exceptions): streamline exception constructors and enhance documentation --- lib/src/exceptions/json_parse_exception.dart | 26 +++++++++---------- .../exceptions/json_serialize_exception.dart | 26 +++++++++---------- .../policy_not_initialized_exception.dart | 8 +++--- lib/src/exceptions/policy_sdk_exception.dart | 22 ++++++++++++++++ 4 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 lib/src/exceptions/policy_sdk_exception.dart diff --git a/lib/src/exceptions/json_parse_exception.dart b/lib/src/exceptions/json_parse_exception.dart index 37de4f7..4196696 100644 --- a/lib/src/exceptions/json_parse_exception.dart +++ b/lib/src/exceptions/json_parse_exception.dart @@ -8,6 +8,19 @@ import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dar /// the specific key that failed, the original error, and any additional /// validation errors. class JsonParseException implements IDetailPolicySDKException { + /// Creates a new JsonParseException. + /// + /// [message] should describe the parsing failure. + /// [key] optionally specifies which JSON key caused the failure. + /// [originalError] optionally provides the original parsing error. + /// [errors] optionally provides a map of field-specific validation errors. + JsonParseException( + this.message, { + this.key, + this.originalError, + this.errors, + }); + @override final String message; @@ -23,19 +36,6 @@ class JsonParseException implements IDetailPolicySDKException { @override final Map? errors; - /// Creates a new JsonParseException. - /// - /// [message] should describe the parsing failure. - /// [key] optionally specifies which JSON key caused the failure. - /// [originalError] optionally provides the original parsing error. - /// [errors] optionally provides a map of field-specific validation errors. - JsonParseException( - this.message, { - this.key, - this.originalError, - this.errors, - }); - @override String toString() { final buffer = StringBuffer('JsonParseException: $message'); diff --git a/lib/src/exceptions/json_serialize_exception.dart b/lib/src/exceptions/json_serialize_exception.dart index 06f4604..af51e19 100644 --- a/lib/src/exceptions/json_serialize_exception.dart +++ b/lib/src/exceptions/json_serialize_exception.dart @@ -7,6 +7,19 @@ import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dar /// occurs when policy objects contain non-serializable data types /// or circular references. class JsonSerializeException implements IDetailPolicySDKException { + /// Creates a new JsonSerializeException. + /// + /// [message] should describe the serialization failure. + /// [key] optionally specifies which object key caused the failure. + /// [originalError] optionally provides the original serialization error. + /// [errors] optionally provides a map of field-specific serialization errors. + JsonSerializeException( + this.message, { + this.key, + this.originalError, + this.errors, + }); + @override final String message; @@ -22,19 +35,6 @@ class JsonSerializeException implements IDetailPolicySDKException { @override final Map? errors; - /// Creates a new JsonSerializeException. - /// - /// [message] should describe the serialization failure. - /// [key] optionally specifies which object key caused the failure. - /// [originalError] optionally provides the original serialization error. - /// [errors] optionally provides a map of field-specific serialization errors. - JsonSerializeException( - this.message, { - this.key, - this.originalError, - this.errors, - }); - @override String toString() { final buffer = StringBuffer('JsonSerializeException: $message'); diff --git a/lib/src/exceptions/policy_not_initialized_exception.dart b/lib/src/exceptions/policy_not_initialized_exception.dart index b9ea0e2..9f12751 100644 --- a/lib/src/exceptions/policy_not_initialized_exception.dart +++ b/lib/src/exceptions/policy_not_initialized_exception.dart @@ -15,13 +15,15 @@ import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dar /// } /// ``` class PolicyNotInitializedException implements IPolicySDKException { + /// Creates a new [PolicyNotInitializedException] with the given [message]. + const PolicyNotInitializedException( + this.message, + ); + /// A message describing the initialization error. @override final String message; - /// Creates a new [PolicyNotInitializedException] with the given [message]. - const PolicyNotInitializedException(this.message); - @override String toString() { final buffer = StringBuffer('PolicyNotInitializedException: $message'); diff --git a/lib/src/exceptions/policy_sdk_exception.dart b/lib/src/exceptions/policy_sdk_exception.dart new file mode 100644 index 0000000..07a4159 --- /dev/null +++ b/lib/src/exceptions/policy_sdk_exception.dart @@ -0,0 +1,22 @@ +import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; + +class PolicySDKException implements IPolicySDKException { + PolicySDKException( + this.message, { + required this.exception, + }); + + @override + final String message; + + final Exception? exception; + + @override + String toString() { + final buffer = StringBuffer('SDKException: $message'); + if (exception != null) { + buffer.write('\nExtra info: ${exception?.toString()}'); + } + return buffer.toString(); + } +} From 64d366dbffed88b9a4c36bd2f1218c7bba4b60ff Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:24:26 +0200 Subject: [PATCH 13/21] docs(exceptions): enhance PolicySDKException documentation --- lib/src/exceptions/policy_sdk_exception.dart | 47 ++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lib/src/exceptions/policy_sdk_exception.dart b/lib/src/exceptions/policy_sdk_exception.dart index 07a4159..de5ca30 100644 --- a/lib/src/exceptions/policy_sdk_exception.dart +++ b/lib/src/exceptions/policy_sdk_exception.dart @@ -1,16 +1,63 @@ import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; +/// A concrete implementation of [IPolicySDKException] that represents +/// errors occurring within the Flutter Policy Engine SDK. +/// +/// This exception class provides detailed error information including +/// a descriptive message and an optional underlying exception that +/// caused the error. It's used throughout the SDK to provide consistent +/// error handling and reporting. +/// +/// Example usage: +/// ```dart +/// try { +/// // SDK operation that might fail +/// } catch (e) { +/// throw PolicySDKException( +/// 'Failed to load policy configuration', +/// exception: e, +/// ); +/// } +/// ``` class PolicySDKException implements IPolicySDKException { + /// Creates a new [PolicySDKException] with the specified error message + /// and optional underlying exception. + /// + /// The [message] should provide a clear, human-readable description + /// of what went wrong. The [exception] parameter can be used to + /// preserve the original exception that caused this error, which + /// is useful for debugging and error tracing. + /// + /// Parameters: + /// - [message]: A descriptive error message explaining what went wrong + /// - [exception]: An optional underlying exception that caused this error PolicySDKException( this.message, { required this.exception, }); + /// A descriptive message explaining the error that occurred. + /// + /// This message should be clear enough for developers to understand + /// what went wrong and potentially how to fix it. @override final String message; + /// The underlying exception that caused this SDK error, if any. + /// + /// This field preserves the original exception for debugging purposes. + /// It can be null if the error was generated directly by the SDK + /// without an underlying exception. final Exception? exception; + /// Returns a string representation of this exception. + /// + /// The returned string includes the SDK exception message and, + /// if available, the underlying exception information for debugging. + /// + /// Returns: + /// A formatted string containing the error message and optional + /// underlying exception details. @override String toString() { final buffer = StringBuffer('SDKException: $message'); From c14a2f2bde689b91d3b974dbfbcfae5dc33b7433 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:30:22 +0200 Subject: [PATCH 14/21] test(exceptions): add comprehensive unit tests for PolicySDKException --- .../exceptions/policy_sdk_exception_test.dart | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 test/exceptions/policy_sdk_exception_test.dart diff --git a/test/exceptions/policy_sdk_exception_test.dart b/test/exceptions/policy_sdk_exception_test.dart new file mode 100644 index 0000000..2abdb44 --- /dev/null +++ b/test/exceptions/policy_sdk_exception_test.dart @@ -0,0 +1,264 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; +import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; + +// Helper classes for testing +class _CustomException implements Exception { + final String detail; + _CustomException(this.detail); + + @override + String toString() => 'CustomException: $detail'; +} + +class _NullToStringException implements Exception { + @override + String toString() => ''; +} + +class _ComplexException implements Exception { + final Map data; + _ComplexException(this.data); + + @override + String toString() => 'ComplexException: ${data.toString()}'; +} + +void main() { + group('PolicySDKException', () { + test('should create exception with required message and exception', () { + const message = 'Failed to load policy configuration'; + final underlyingException = Exception('Network error'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + expect(exception.message, equals(message)); + expect(exception.exception, equals(underlyingException)); + }); + + test('should create exception with null underlying exception', () { + const message = 'Failed to load policy configuration'; + final exception = PolicySDKException( + message, + exception: null, + ); + + expect(exception.message, equals(message)); + expect(exception.exception, isNull); + }); + + test('should implement IPolicySDKException interface', () { + const message = 'Failed to load policy configuration'; + final underlyingException = Exception('Network error'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + expect(exception, isA()); + expect(exception, isA()); + }); + + test( + 'should return correct string representation with underlying exception', + () { + const message = 'Failed to load policy configuration'; + final underlyingException = Exception('Network error'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + expect( + exception.toString(), + equals('SDKException: $message\nExtra info: Exception: Network error'), + ); + }); + + test( + 'should return correct string representation without underlying exception', + () { + const message = 'Failed to load policy configuration'; + final exception = PolicySDKException( + message, + exception: null, + ); + + expect(exception.toString(), equals('SDKException: $message')); + }); + + test('should handle different types of underlying exceptions', () { + const message = 'Failed to load policy configuration'; + + // Test with FormatException + final formatError = FormatException('Invalid format'); + final exceptionWithFormatError = PolicySDKException( + message, + exception: formatError, + ); + + expect(exceptionWithFormatError.exception, equals(formatError)); + expect( + exceptionWithFormatError.toString(), + contains('FormatException: Invalid format'), + ); + + // Test with another Exception type + final timeoutError = TimeoutException('Operation timed out'); + final exceptionWithTimeoutError = PolicySDKException( + message, + exception: timeoutError, + ); + + expect(exceptionWithTimeoutError.exception, equals(timeoutError)); + expect( + exceptionWithTimeoutError.toString(), + contains('TimeoutException: Operation timed out'), + ); + }); + + test('should handle custom exception types', () { + const message = 'Failed to load policy configuration'; + + final customError = _CustomException('Custom error detail'); + final exception = PolicySDKException( + message, + exception: customError, + ); + + expect(exception.exception, equals(customError)); + expect( + exception.toString(), + equals( + 'SDKException: $message\nExtra info: CustomException: Custom error detail'), + ); + }); + + test('should handle special characters in message', () { + const message = 'Failed to load policy with special chars: !@#\$%^&*()'; + final underlyingException = Exception('Special error: !@#\$%^&*()'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + expect(exception.message, equals(message)); + expect(exception.exception, equals(underlyingException)); + expect( + exception.toString(), + equals( + 'SDKException: $message\nExtra info: Exception: Special error: !@#\$%^&*()'), + ); + }); + + test('should handle empty message', () { + const message = ''; + final underlyingException = Exception('Network error'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + expect(exception.message, equals(message)); + expect(exception.exception, equals(underlyingException)); + expect( + exception.toString(), + equals('SDKException: \nExtra info: Exception: Network error'), + ); + }); + + test('should handle multiline message', () { + const message = + 'Failed to load policy configuration\nThis is a multiline error message'; + final underlyingException = Exception('Network error'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + expect(exception.message, equals(message)); + expect(exception.exception, equals(underlyingException)); + expect( + exception.toString(), + equals('SDKException: $message\nExtra info: Exception: Network error'), + ); + }); + + test('should handle exception with null toString() result', () { + const message = 'Failed to load policy configuration'; + + final nullToStringError = _NullToStringException(); + final exception = PolicySDKException( + message, + exception: nullToStringError, + ); + + expect(exception.exception, equals(nullToStringError)); + expect( + exception.toString(), + equals('SDKException: $message\nExtra info: '), + ); + }); + + test('should handle exception with complex toString() result', () { + const message = 'Failed to load policy configuration'; + + final complexError = _ComplexException({ + 'errorCode': 500, + 'details': ['detail1', 'detail2'], + 'timestamp': DateTime.now(), + }); + + final exception = PolicySDKException( + message, + exception: complexError, + ); + + expect(exception.exception, equals(complexError)); + expect( + exception.toString(), + contains('SDKException: $message\nExtra info: ComplexException:'), + ); + }); + + test('should be immutable after creation', () { + const message = 'Failed to load policy configuration'; + final underlyingException = Exception('Network error'); + final exception = PolicySDKException( + message, + exception: underlyingException, + ); + + // Verify that the exception object is immutable + expect(exception.message, equals(message)); + expect(exception.exception, equals(underlyingException)); + + // The fields should remain the same after multiple accesses + expect(exception.message, equals(message)); + expect(exception.exception, equals(underlyingException)); + }); + + test('should handle multiple exceptions with same message', () { + const message = 'Failed to load policy configuration'; + final exception1 = Exception('Network error 1'); + final exception2 = Exception('Network error 2'); + + final sdkException1 = PolicySDKException( + message, + exception: exception1, + ); + + final sdkException2 = PolicySDKException( + message, + exception: exception2, + ); + + expect(sdkException1.message, equals(sdkException2.message)); + expect(sdkException1.exception, isNot(equals(sdkException2.exception))); + expect(sdkException1.toString(), isNot(equals(sdkException2.toString()))); + }); + }); +} From e298e725e132b5ea018f8d8c9570858c75c83c01 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:42:47 +0200 Subject: [PATCH 15/21] feat: integrate PolicySDKException for clarity in role management --- lib/flutter_policy_engine.dart | 1 + lib/src/core/policy_manager.dart | 42 +++++++++----------- lib/src/exceptions/policy_sdk_exception.dart | 2 +- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/lib/flutter_policy_engine.dart b/lib/flutter_policy_engine.dart index 68ffa89..add6ba0 100644 --- a/lib/flutter_policy_engine.dart +++ b/lib/flutter_policy_engine.dart @@ -4,3 +4,4 @@ export 'src/core/policy_manager.dart'; export 'src/widgets/policy_widget.dart'; export 'src/core/policy_provider.dart'; export 'src/models/role.dart'; +export 'src/exceptions/policy_sdk_exception.dart'; diff --git a/lib/src/core/policy_manager.dart b/lib/src/core/policy_manager.dart index 3ba4ffe..f27792e 100644 --- a/lib/src/core/policy_manager.dart +++ b/lib/src/core/policy_manager.dart @@ -3,6 +3,7 @@ import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dar import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart'; import 'package:flutter_policy_engine/src/core/memory_policy_storage.dart'; import 'package:flutter_policy_engine/src/core/role_evaluator.dart'; +import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; import 'package:flutter_policy_engine/src/models/role.dart'; import 'package:flutter_policy_engine/src/utils/external_asset_handler.dart'; import 'package:flutter_policy_engine/src/utils/json_handler.dart'; @@ -66,9 +67,7 @@ class PolicyManager extends ChangeNotifier { /// are JSON representations of [Role] objects. /// /// Throws: - /// - [JsonParseException] if policy parsing fails completely - /// - [FormatException] if the JSON data is malformed - /// - [ArgumentError] if policy parsing fails + /// - [PolicySDKException] if initialization fails for any reason, including malformed JSON, parsing errors, or storage issues. Future initialize(Map jsonPolicies) async { try { LogHandler.info( @@ -171,8 +170,10 @@ class PolicyManager extends ChangeNotifier { operation: 'policy_manager_initialize_error', ); - // Re-throw to allow caller to handle the error - rethrow; + throw PolicySDKException( + 'Failed to initialize policy manager', + exception: e is Exception ? e : null, + ); } } @@ -236,20 +237,8 @@ class PolicyManager extends ChangeNotifier { /// /// ## Throws /// - /// * [ArgumentError] - If [assetPath] is empty or null - /// - /// ## Dependencies - /// - /// This method depends on: - /// * [ExternalAssetHandler] - For loading and parsing the asset file - /// * [initialize] - For processing the loaded policy data - /// * [LogHandler] - For error logging and debugging - /// + /// * [PolicySDKException] - If initialization fails for any reason, including malformed JSON, parsing errors, or storage issues. Future initializeFromJsonAssets(String assetPath) async { - if (assetPath.isEmpty) { - throw ArgumentError('Asset path cannot be empty'); - } - try { final assetHandler = ExternalAssetHandler(assetPath: assetPath); final jsonPolicies = await assetHandler.loadAssets(); @@ -368,6 +357,11 @@ class PolicyManager extends ChangeNotifier { context: {'asset_path': assetPath}, operation: 'policy_manager_initialize_from_json_assets_error', ); + + throw PolicySDKException( + 'Failed to initialize policy manager from JSON assets', + exception: e is Exception ? e : null, + ); } } @@ -402,11 +396,11 @@ class PolicyManager extends ChangeNotifier { /// [role] must not be null and should have a valid name. /// /// Throws: - /// - [ArgumentError] if [role] is null or has an invalid name + /// - [PolicySDKException] if [role] is null or has an invalid name /// - Storage-related exceptions if persistence fails Future addRole(Role role) async { if (role.name.isEmpty) { - throw ArgumentError('Role name cannot be empty'); + throw PolicySDKException('Role name cannot be empty'); } _roles[role.name] = role; @@ -428,11 +422,11 @@ class PolicyManager extends ChangeNotifier { /// [roleName] must not be null or empty. /// /// Throws: - /// - [ArgumentError] if [roleName] is null or empty + /// - [PolicySDKException] if [roleName] is null or empty /// - Storage-related exceptions if persistence fails Future removeRole(String roleName) async { if (roleName.isEmpty) { - throw ArgumentError('Role name cannot be empty'); + throw PolicySDKException('Role name cannot be empty'); } _roles.remove(roleName); @@ -454,11 +448,11 @@ class PolicyManager extends ChangeNotifier { /// [role] must not be null and should have a valid name. /// /// Throws: - /// - [ArgumentError] if [roleName] is null/empty or [role] is null/invalid + /// - [PolicySDKException] if [roleName] is null/empty or [role] is null/invalid /// - Storage-related exceptions if persistence fails Future updateRole(String roleName, Role role) async { if (roleName.isEmpty && role.name.isEmpty) { - throw ArgumentError('Role name cannot be empty'); + throw PolicySDKException('Role name cannot be empty'); } _roles[roleName] = role; diff --git a/lib/src/exceptions/policy_sdk_exception.dart b/lib/src/exceptions/policy_sdk_exception.dart index de5ca30..163c6b4 100644 --- a/lib/src/exceptions/policy_sdk_exception.dart +++ b/lib/src/exceptions/policy_sdk_exception.dart @@ -33,7 +33,7 @@ class PolicySDKException implements IPolicySDKException { /// - [exception]: An optional underlying exception that caused this error PolicySDKException( this.message, { - required this.exception, + this.exception, }); /// A descriptive message explaining the error that occurred. From fa557334c2a2986d31206394aeb6f8c33da09cbf Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:48:18 +0200 Subject: [PATCH 16/21] test(policy_manager): replace ArgumentError with PolicySDKException in role management tests --- test/core/policy_manager_test.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/core/policy_manager_test.dart b/test/core/policy_manager_test.dart index 14f7d7e..c9d0007 100644 --- a/test/core/policy_manager_test.dart +++ b/test/core/policy_manager_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_policy_engine/src/core/policy_manager.dart'; import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dart'; @@ -493,7 +494,7 @@ void main() { test('should handle empty asset path', () async { expect( () => policyManager.initializeFromJsonAssets(''), - throwsA(isA()), + throwsA(isA()), ); }); @@ -982,7 +983,7 @@ void main() { expect( () => policyManager.addRole(invalidRole), - throwsA(isA()), + throwsA(isA()), ); }); @@ -1085,7 +1086,7 @@ void main() { expect( () => policyManager.removeRole(''), - throwsA(isA()), + throwsA(isA()), ); }); @@ -1209,7 +1210,7 @@ void main() { expect( () => policyManager.updateRole('', role), - throwsA(isA()), + throwsA(isA()), ); }); @@ -1222,7 +1223,7 @@ void main() { expect( () => policyManager.updateRole('', invalidRole), - throwsA(isA()), + throwsA(isA()), ); }); From 37e58f61fc9fc02026d15abc0626bd722f0225e8 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 18:52:07 +0200 Subject: [PATCH 17/21] test(policy_manager): update error handling to use PolicySDKException in integration tests --- test/core/integration_test.dart | 4 +++- test/core/policy_manager_test.dart | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/test/core/integration_test.dart b/test/core/integration_test.dart index 0e66528..e513f27 100644 --- a/test/core/integration_test.dart +++ b/test/core/integration_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter_policy_engine/flutter_policy_engine.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_policy_engine/src/core/policy_manager.dart'; import 'package:flutter_policy_engine/src/core/memory_policy_storage.dart'; import 'package:flutter_policy_engine/src/core/role_evaluator.dart'; +import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; void main() { group('Core Integration Tests', () { @@ -169,7 +171,7 @@ void main() { // Should throw when storage fails expect(() => failingManager.initialize(jsonPolicies), - throwsA(isA())); + throwsA(isA())); expect(failingManager.isInitialized, isFalse); }); diff --git a/test/core/policy_manager_test.dart b/test/core/policy_manager_test.dart index c9d0007..8dd6343 100644 --- a/test/core/policy_manager_test.dart +++ b/test/core/policy_manager_test.dart @@ -207,7 +207,7 @@ void main() { }; expect(() => policyManager.initialize(jsonPolicies), - throwsA(isA())); + throwsA(isA())); expect(policyManager.isInitialized, isFalse); }); @@ -343,7 +343,7 @@ void main() { mockStorage.setShouldThrowOnSave(true); expect(() => policyManager.initialize(jsonPolicies), - throwsA(isA())); + throwsA(isA())); expect(policyManager.isInitialized, isFalse); }); @@ -425,7 +425,7 @@ void main() { expect( () => policyManager.initialize(jsonPolicies), - throwsA(isA()), + throwsA(isA()), ); expect(policyManager.isInitialized, isFalse); @@ -444,7 +444,7 @@ void main() { expect( () => policyManager.initialize(jsonPolicies), - throwsA(isA()), + throwsA(isA()), ); }); }); @@ -931,11 +931,14 @@ void main() { // Make storage throw on save mockStorage.setShouldThrowOnSave(true); - // The method catches exceptions and doesn't rethrow them - await policyManager - .initializeFromJsonAssets('assets/policies/test.json'); + // The method should throw PolicySDKException when storage fails + expect( + () => policyManager + .initializeFromJsonAssets('assets/policies/test.json'), + throwsA(isA()), + ); - // Should handle gracefully and not initialize due to storage error + // Should not be initialized due to storage error expect(policyManager.isInitialized, isFalse); // Clean up mock message handler From a66b6b748865b9213e6bfc3292dd37d882bfdefc Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 19:02:51 +0200 Subject: [PATCH 18/21] test(policy_provider): add comprehensive widget tests for PolicyProvider functionality --- test/core/policy_provider_test.dart | 568 ++++++++++++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 test/core/policy_provider_test.dart diff --git a/test/core/policy_provider_test.dart b/test/core/policy_provider_test.dart new file mode 100644 index 0000000..2d3de1c --- /dev/null +++ b/test/core/policy_provider_test.dart @@ -0,0 +1,568 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_policy_engine/src/core/policy_provider.dart'; +import 'package:flutter_policy_engine/src/core/policy_manager.dart'; +import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dart'; +import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart'; + +/// Mock implementation of IPolicyStorage for testing +// ignore: must_be_immutable +class MockPolicyStorage implements IPolicyStorage { + Map _policies = {}; + + @override + Future> loadPolicies() async { + return Map.from(_policies); + } + + @override + Future savePolicies(Map policies) async { + _policies = Map.from(policies); + } + + @override + Future clearPolicies() async { + _policies.clear(); + } +} + +/// Mock implementation of IPolicyEvaluator for testing +class MockPolicyEvaluator implements IPolicyEvaluator { + @override + bool evaluate(String roleName, String content) { + return true; + } +} + +/// Test widget that accesses PolicyProvider +class TestConsumerWidget extends StatelessWidget { + const TestConsumerWidget({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Test the 'of' method + Builder( + builder: (context) { + final provider = PolicyProvider.of(context); + return Text('provider_found: ${provider != null}'); + }, + ), + // Test the 'policyManagerOf' method + Builder( + builder: (context) { + try { + final policyManager = PolicyProvider.policyManagerOf(context); + return Text('manager_found: ${policyManager != null}'); + } catch (e) { + return Text('error: ${e.toString()}'); + } + }, + ), + ], + ), + ); + } +} + +/// Test widget that triggers rebuilds +class RebuildTestWidget extends StatefulWidget { + const RebuildTestWidget({super.key}); + + @override + State createState() => _RebuildTestWidgetState(); +} + +class _RebuildTestWidgetState extends State { + int rebuildCount = 0; + + @override + Widget build(BuildContext context) { + rebuildCount++; + final policyManager = PolicyProvider.policyManagerOf(context); + return Text( + 'rebuild_count: $rebuildCount, initialized: ${policyManager.isInitialized}'); + } +} + +/// Helper function to wrap widgets in MaterialApp for testing +Widget wrapWithMaterialApp(Widget child) { + return MaterialApp(home: child); +} + +void main() { + group('PolicyProvider', () { + late PolicyManager policyManager; + late MockPolicyStorage mockStorage; + late MockPolicyEvaluator mockEvaluator; + + setUp(() { + mockStorage = MockPolicyStorage(); + mockEvaluator = MockPolicyEvaluator(); + policyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + }); + + group('Constructor', () { + testWidgets('should create PolicyProvider with required parameters', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const Text('Test Child'), + ), + ), + ); + + expect(find.text('Test Child'), findsOneWidget); + }); + + testWidgets('should accept key parameter', (WidgetTester tester) async { + const key = Key('test_key'); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + key: key, + policyManager: policyManager, + child: const Text('Test Child'), + ), + ), + ); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets('should require policyManager parameter', + (WidgetTester tester) async { + // This test verifies that the constructor requires policyManager + // The actual validation would be done by Dart's type system + expect( + () => PolicyProvider( + policyManager: policyManager, + child: const Text('Test Child'), + ), + returnsNormally, + ); + }); + + testWidgets('should require child parameter', + (WidgetTester tester) async { + // This test verifies that the constructor requires child + // The actual validation would be done by Dart's type system + expect( + () => PolicyProvider( + policyManager: policyManager, + child: const Text('Test Child'), + ), + returnsNormally, + ); + }); + }); + + group('PolicyProvider.of', () { + testWidgets('should return PolicyProvider when found in widget tree', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const TestConsumerWidget(), + ), + ), + ); + + expect(find.text('provider_found: true'), findsOneWidget); + }); + + testWidgets( + 'should return null when PolicyProvider not found in widget tree', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + const TestConsumerWidget(), + ), + ); + + expect(find.text('provider_found: false'), findsOneWidget); + }); + + testWidgets('should establish dependency for rebuilds', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + + // Create a new PolicyManager instance to trigger rebuild + final newPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: newPolicyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 2, initialized: false'), findsOneWidget); + }); + }); + + group('PolicyProvider.policyManagerOf', () { + testWidgets('should return PolicyManager when PolicyProvider is found', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const TestConsumerWidget(), + ), + ), + ); + + expect(find.text('manager_found: true'), findsOneWidget); + }); + + testWidgets('should throw StateError when PolicyProvider not found', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + const TestConsumerWidget(), + ), + ); + + expect(find.textContaining('error:'), findsOneWidget); + }); + + testWidgets('should establish dependency for rebuilds', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + + // Create a new PolicyManager instance to trigger rebuild + final newPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: newPolicyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 2, initialized: false'), findsOneWidget); + }); + }); + + group('updateShouldNotify', () { + testWidgets('should return true when policyManager changes', + (WidgetTester tester) async { + final widget = PolicyProvider( + policyManager: policyManager, + child: const Text('Test'), + ); + + final newPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + final newWidget = PolicyProvider( + policyManager: newPolicyManager, + child: const Text('Test'), + ); + + expect(widget.updateShouldNotify(newWidget), isTrue); + }); + + testWidgets('should return false when policyManager is the same', + (WidgetTester tester) async { + final widget = PolicyProvider( + policyManager: policyManager, + child: const Text('Test'), + ); + + final sameWidget = PolicyProvider( + policyManager: policyManager, + child: const Text('Test'), + ); + + expect(widget.updateShouldNotify(sameWidget), isFalse); + }); + + testWidgets('should trigger rebuild when policyManager changes', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + + // Create a new PolicyManager instance + final newPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: newPolicyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 2, initialized: false'), findsOneWidget); + }); + }); + + group('Integration Tests', () { + testWidgets('should work with nested PolicyProviders', + (WidgetTester tester) async { + final innerPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: PolicyProvider( + policyManager: innerPolicyManager, + child: const TestConsumerWidget(), + ), + ), + ), + ); + + // Should find the inner PolicyProvider + expect(find.text('provider_found: true'), findsOneWidget); + expect(find.text('manager_found: true'), findsOneWidget); + }); + + testWidgets('should handle multiple consumers in widget tree', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const Column( + children: const [ + TestConsumerWidget(), + TestConsumerWidget(), + RebuildTestWidget(), + ], + ), + ), + ), + ); + + expect(find.text('provider_found: true'), findsNWidgets(2)); + expect(find.text('manager_found: true'), findsNWidgets(2)); + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + }); + + testWidgets('should handle deep widget tree', + (WidgetTester tester) async { + Widget buildDeepTree(int depth) { + if (depth <= 0) { + return const TestConsumerWidget(); + } + return Container( + child: buildDeepTree(depth - 1), + ); + } + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: buildDeepTree(10), + ), + ), + ); + + expect(find.text('provider_found: true'), findsOneWidget); + expect(find.text('manager_found: true'), findsOneWidget); + }); + + testWidgets('should handle PolicyManager lifecycle', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + + // Initialize the policy manager with some valid policies + await policyManager.initialize({ + 'test_role': ['content1', 'content2'], + }); + + // Create a new PolicyManager instance that's already initialized + final initializedPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + await initializedPolicyManager.initialize({ + 'test_role': ['content1', 'content2'], + }); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: initializedPolicyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 2, initialized: true'), findsOneWidget); + }); + }); + + group('Error Handling', () { + testWidgets('should handle null context gracefully', + (WidgetTester tester) async { + // This test verifies that the methods handle edge cases + // The actual null context handling would be done by Flutter framework + expect( + () => PolicyProvider( + policyManager: policyManager, + child: const TestConsumerWidget(), + ), + returnsNormally, + ); + }); + + testWidgets('should handle PolicyManager with errors', + (WidgetTester tester) async { + // Create a PolicyManager that might throw errors + final errorPolicyManager = PolicyManager( + storage: MockPolicyStorage(), + evaluator: MockPolicyEvaluator(), + ); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: errorPolicyManager, + child: const TestConsumerWidget(), + ), + ), + ); + + expect(find.text('provider_found: true'), findsOneWidget); + expect(find.text('manager_found: true'), findsOneWidget); + }); + }); + + group('Performance Tests', () { + testWidgets('should not cause unnecessary rebuilds', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + + // Pump again without changing the PolicyManager + await tester.pump(); + + // Should not rebuild + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + }); + + testWidgets('should handle rapid PolicyManager changes efficiently', + (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: policyManager, + child: const RebuildTestWidget(), + ), + ), + ); + + expect( + find.text('rebuild_count: 1, initialized: false'), findsOneWidget); + + // Rapidly change PolicyManager instances + for (int i = 0; i < 5; i++) { + final newPolicyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + + await tester.pumpWidget( + wrapWithMaterialApp( + PolicyProvider( + policyManager: newPolicyManager, + child: const RebuildTestWidget(), + ), + ), + ); + } + + expect( + find.text('rebuild_count: 6, initialized: false'), findsOneWidget); + }); + }); + }); +} From 80dc4864837f4bfbf2197164f1390c4aa29d948d Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 19:04:39 +0200 Subject: [PATCH 19/21] test(integration): simplify policy manager retrieval and enhance widget test assertions --- test/core/integration_test.dart | 2 -- test/core/policy_provider_test.dart | 6 +++--- test/exceptions/policy_sdk_exception_test.dart | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/test/core/integration_test.dart b/test/core/integration_test.dart index e513f27..108253a 100644 --- a/test/core/integration_test.dart +++ b/test/core/integration_test.dart @@ -1,9 +1,7 @@ import 'package:flutter_policy_engine/flutter_policy_engine.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_policy_engine/src/core/policy_manager.dart'; import 'package:flutter_policy_engine/src/core/memory_policy_storage.dart'; import 'package:flutter_policy_engine/src/core/role_evaluator.dart'; -import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; void main() { group('Core Integration Tests', () { diff --git a/test/core/policy_provider_test.dart b/test/core/policy_provider_test.dart index 2d3de1c..253c7d9 100644 --- a/test/core/policy_provider_test.dart +++ b/test/core/policy_provider_test.dart @@ -55,8 +55,8 @@ class TestConsumerWidget extends StatelessWidget { Builder( builder: (context) { try { - final policyManager = PolicyProvider.policyManagerOf(context); - return Text('manager_found: ${policyManager != null}'); + final _ = PolicyProvider.policyManagerOf(context); + return const Text('manager_found: true'); } catch (e) { return Text('error: ${e.toString()}'); } @@ -388,7 +388,7 @@ void main() { PolicyProvider( policyManager: policyManager, child: const Column( - children: const [ + children: [ TestConsumerWidget(), TestConsumerWidget(), RebuildTestWidget(), diff --git a/test/exceptions/policy_sdk_exception_test.dart b/test/exceptions/policy_sdk_exception_test.dart index 2abdb44..be329e4 100644 --- a/test/exceptions/policy_sdk_exception_test.dart +++ b/test/exceptions/policy_sdk_exception_test.dart @@ -94,7 +94,7 @@ void main() { const message = 'Failed to load policy configuration'; // Test with FormatException - final formatError = FormatException('Invalid format'); + const formatError = FormatException('Invalid format'); final exceptionWithFormatError = PolicySDKException( message, exception: formatError, From 5d7878c0988c7858142ee22296da5106d3afc159 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 19:27:02 +0200 Subject: [PATCH 20/21] test(policy_widget): implement comprehensive widget tests --- lib/src/widgets/policy_widget.dart | 8 +- test/widgets/policy_widget_test.dart | 394 +++++++++++++++++++++++++++ 2 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 test/widgets/policy_widget_test.dart diff --git a/lib/src/widgets/policy_widget.dart b/lib/src/widgets/policy_widget.dart index 0dd9da7..c3b3ba5 100644 --- a/lib/src/widgets/policy_widget.dart +++ b/lib/src/widgets/policy_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_policy_engine/flutter_policy_engine.dart'; import 'package:flutter_policy_engine/src/core/policy_provider.dart'; import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; @@ -70,14 +71,17 @@ class PolicyWidget extends StatelessWidget { } catch (e) { if (e is IPolicySDKException) { assert(() { - throw FlutterError('Error en PolicyWidget: ${e.message}'); + throw PolicySDKException('Error en PolicyWidget: ${e.message}'); }()); // On production, deny access silently onAccessDenied?.call(); return fallback ?? const SizedBox.shrink(); } - rethrow; + throw PolicySDKException( + 'Error en PolicyWidget', + exception: e as Exception, + ); } } } diff --git a/test/widgets/policy_widget_test.dart b/test/widgets/policy_widget_test.dart new file mode 100644 index 0000000..b308fcb --- /dev/null +++ b/test/widgets/policy_widget_test.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_policy_engine/src/widgets/policy_widget.dart'; +import 'package:flutter_policy_engine/src/core/policy_provider.dart'; +import 'package:flutter_policy_engine/src/core/policy_manager.dart'; +import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dart'; +import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart'; +import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; +import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; + +/// Mock implementation of IPolicyStorage for testing +// ignore: must_be_immutable +class MockPolicyStorage implements IPolicyStorage { + Map _policies = {}; + + @override + Future> loadPolicies() async { + return Map.from(_policies); + } + + @override + Future savePolicies(Map policies) async { + _policies = Map.from(policies); + } + + @override + Future clearPolicies() async { + _policies.clear(); + } +} + +/// Mock implementation of IPolicyEvaluator for testing +// ignore: must_be_immutable +class MockPolicyEvaluator implements IPolicyEvaluator { + final Map _evaluationResults = {}; + bool _shouldThrowError = false; + + void setEvaluationResult(String roleName, String content, bool result) { + _evaluationResults['$roleName:$content'] = result; + } + + void setShouldThrowError(bool value) => _shouldThrowError = value; + + @override + bool evaluate(String roleName, String content) { + if (_shouldThrowError) { + throw PolicySDKException('Mock evaluation error'); + } + return _evaluationResults['$roleName:$content'] ?? false; + } +} + +/// Test widget that tracks callback invocations +class CallbackTracker { + int accessDeniedCallCount = 0; + + void onAccessDenied() { + accessDeniedCallCount++; + } + + void reset() { + accessDeniedCallCount = 0; + } +} + +void main() { + group('PolicyWidget', () { + late PolicyManager policyManager; + late MockPolicyStorage mockStorage; + late MockPolicyEvaluator mockEvaluator; + late CallbackTracker callbackTracker; + + setUp(() { + mockStorage = MockPolicyStorage(); + mockEvaluator = MockPolicyEvaluator(); + policyManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + callbackTracker = CallbackTracker(); + }); + + Widget createTestApp({ + required Widget child, + PolicyManager? manager, + }) { + return MaterialApp( + home: PolicyProvider( + policyManager: manager ?? policyManager, + child: child, + ), + ); + } + + group('Access Control', () { + testWidgets('should display child when access is granted', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('admin', 'dashboard', true); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: 'admin', + content: 'dashboard', + child: Text('Dashboard Content'), + ), + ), + ); + + // Assert + expect(find.text('Dashboard Content'), findsOneWidget); + expect(callbackTracker.accessDeniedCallCount, 0); + }); + + testWidgets('should display fallback when access is denied', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('user', 'admin-panel', false); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: 'user', + content: 'admin-panel', + child: Text('Admin Panel'), + fallback: Text('Access Denied'), + ), + ), + ); + + // Assert + expect(find.text('Admin Panel'), findsNothing); + expect(find.text('Access Denied'), findsOneWidget); + }); + + testWidgets( + 'should display empty SizedBox when access denied and no fallback', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('guest', 'premium-content', false); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: 'guest', + content: 'premium-content', + child: Text('Premium Content'), + ), + ), + ); + + // Assert + expect(find.text('Premium Content'), findsNothing); + expect(find.byType(SizedBox), findsOneWidget); + }); + }); + + group('Callback Behavior', () { + testWidgets('should call onAccessDenied when access is denied', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('user', 'restricted', false); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: PolicyWidget( + role: 'user', + content: 'restricted', + child: const Text('Restricted Content'), + onAccessDenied: callbackTracker.onAccessDenied, + ), + ), + ); + + // Assert + expect(callbackTracker.accessDeniedCallCount, 1); + }); + + testWidgets('should not call onAccessDenied when access is granted', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('admin', 'public', true); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: PolicyWidget( + role: 'admin', + content: 'public', + child: const Text('Public Content'), + onAccessDenied: callbackTracker.onAccessDenied, + ), + ), + ); + + // Assert + expect(callbackTracker.accessDeniedCallCount, 0); + }); + + testWidgets('should handle null onAccessDenied callback gracefully', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('user', 'restricted', false); + await policyManager.initialize({}); + + // Act & Assert - should not throw + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: 'user', + content: 'restricted', + child: Text('Restricted Content'), + onAccessDenied: null, + ), + ), + ); + + // Should render fallback without error + expect(find.byType(SizedBox), findsOneWidget); + }); + }); + + group('Widget Tree Integration', () { + testWidgets('should work with nested PolicyWidgets', (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('admin', 'dashboard', true); + mockEvaluator.setEvaluationResult('admin', 'settings', false); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: 'admin', + content: 'dashboard', + child: Column( + children: [ + Text('Dashboard Header'), + PolicyWidget( + role: 'admin', + content: 'settings', + child: Text('Settings Panel'), + fallback: Text('Settings Access Denied'), + ), + ], + ), + ), + ), + ); + + // Assert + expect(find.text('Dashboard Header'), findsOneWidget); + expect(find.text('Settings Panel'), findsNothing); + expect(find.text('Settings Access Denied'), findsOneWidget); + }); + }); + + group('Edge Cases', () { + testWidgets('should handle empty role and content strings', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('', '', false); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: '', + content: '', + child: Text('Content'), + fallback: Text('Empty Access Denied'), + ), + ), + ); + + // Assert + expect(find.text('Content'), findsNothing); + expect(find.text('Empty Access Denied'), findsOneWidget); + }); + + testWidgets('should handle special characters in role and content', + (tester) async { + // Arrange + const specialRole = 'admin@company.com'; + const specialContent = 'api/v1/users'; + mockEvaluator.setEvaluationResult(specialRole, specialContent, true); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: specialRole, + content: specialContent, + child: Text('Special Content'), + ), + ), + ); + + // Assert + expect(find.text('Special Content'), findsOneWidget); + }); + + testWidgets('should handle complex child widgets', (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('admin', 'complex', true); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: PolicyWidget( + role: 'admin', + content: 'complex', + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text('Complex Widget'), + ElevatedButton( + onPressed: () {}, + child: const Text('Button'), + ), + ], + ), + ), + ), + ), + ); + + // Assert + expect(find.text('Complex Widget'), findsOneWidget); + expect(find.text('Button'), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + }); + }); + + group('Performance and Rebuilds', () { + testWidgets( + 'should not rebuild unnecessarily when policy manager changes', + (tester) async { + // Arrange + mockEvaluator.setEvaluationResult('user', 'content', true); + await policyManager.initialize({}); + + // Act + await tester.pumpWidget( + createTestApp( + child: const PolicyWidget( + role: 'user', + content: 'content', + child: Text('Content'), + ), + ), + ); + + // Assert initial state + expect(find.text('Content'), findsOneWidget); + + // Update policy manager (should trigger rebuild) + final newManager = PolicyManager( + storage: mockStorage, + evaluator: mockEvaluator, + ); + await newManager.initialize({}); + + await tester.pumpWidget( + createTestApp( + manager: newManager, + child: const PolicyWidget( + role: 'user', + content: 'content', + child: Text('Content'), + ), + ), + ); + + // Should still show content + expect(find.text('Content'), findsOneWidget); + }); + }); + }); +} From 3b3cea75d960e69edfa834ff88821abc48aa53d0 Mon Sep 17 00:00:00 2001 From: David Garcia Ruiz Date: Mon, 28 Jul 2025 19:27:43 +0200 Subject: [PATCH 21/21] refactor(policy_widget): remove unused import for policy_provider to enhance clarity --- lib/src/widgets/policy_widget.dart | 1 - test/widgets/policy_widget_test.dart | 1 - 2 files changed, 2 deletions(-) diff --git a/lib/src/widgets/policy_widget.dart b/lib/src/widgets/policy_widget.dart index c3b3ba5..d7a059e 100644 --- a/lib/src/widgets/policy_widget.dart +++ b/lib/src/widgets/policy_widget.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_policy_engine/flutter_policy_engine.dart'; -import 'package:flutter_policy_engine/src/core/policy_provider.dart'; import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; /// A widget that conditionally displays its [child] based on policy access control. diff --git a/test/widgets/policy_widget_test.dart b/test/widgets/policy_widget_test.dart index b308fcb..311037a 100644 --- a/test/widgets/policy_widget_test.dart +++ b/test/widgets/policy_widget_test.dart @@ -5,7 +5,6 @@ import 'package:flutter_policy_engine/src/core/policy_provider.dart'; import 'package:flutter_policy_engine/src/core/policy_manager.dart'; import 'package:flutter_policy_engine/src/core/interfaces/i_policy_evaluator.dart'; import 'package:flutter_policy_engine/src/core/interfaces/i_policy_storage.dart'; -import 'package:flutter_policy_engine/src/exceptions/i_policy_sdk_exceptions.dart'; import 'package:flutter_policy_engine/src/exceptions/policy_sdk_exception.dart'; /// Mock implementation of IPolicyStorage for testing