From e0d0b9de846a88c6f1a55151078efc93ce4a5644 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 16 Oct 2024 16:47:52 +0100 Subject: [PATCH] feat: Support transient identities and traits - Drop Flutter 2 support - Publish via official Dart OIDC-enabled workflow - Support transient identities and traits - Integrate `flagsmith_core` and `flagsmith_storage_sharedpreferences` - Minor unit test improvements --- .github/workflows/publish_master.yml | 35 +-- .github/workflows/pull-request.yml | 24 +- CHANGELOG.md | 15 ++ example/lib/di.dart | 1 - example/lib/main.dart | 1 - example/lib/models/custom_storage.dart | 2 +- example/pubspec.lock | 230 ++++++++++--------- example/pubspec.yaml | 3 +- lib/flagsmith.dart | 5 +- lib/src/core/core.dart | 10 +- lib/src/core/crud_storage.dart | 16 ++ lib/src/core/exceptions.dart | 2 +- lib/src/core/extensions/converters.dart | 8 + lib/src/core/extensions/string_ext.dart | 3 + lib/src/core/model/feature.dart | 68 ++++++ lib/src/core/model/feature.g.dart | 29 +++ lib/src/core/model/flag.dart | 98 ++++++++ lib/src/core/model/flag.g.dart | 29 +++ lib/src/core/model/flags_and_traits.dart | 28 +++ lib/src/core/model/flags_and_traits.g.dart | 25 ++ lib/src/core/model/identity.dart | 25 ++ lib/src/core/model/identity.g.dart | 28 +++ lib/src/core/model/index.dart | 8 + lib/src/core/model/loading.dart | 1 + lib/src/core/model/trait.dart | 118 ++++++++++ lib/src/core/model/trait.g.dart | 47 ++++ lib/src/core/storage/in_memory_storage.dart | 84 +++++++ lib/src/core/storage_provider.dart | 169 ++++++++++++++ lib/src/core/storage_type.dart | 2 + lib/src/core/tools/security.dart | 46 ++++ lib/src/flagsmith_client.dart | 47 ++-- lib/src/storage/sharedpreferences_store.dart | 85 +++++++ lib/src/storage/storage.dart | 1 + pubspec.lock | 192 ++++++++++++++-- pubspec.yaml | 17 +- test/core/flagsmith_core_test.dart | 97 ++++++++ test/core/models/flag_test.dart | 115 ++++++++++ test/core/models/future_test.dart | 18 ++ test/core/models/identity_test.dart | 46 ++++ test/core/models/trait_identity_test.dart | 96 ++++++++ test/core/models/trait_test.dart | 222 ++++++++++++++++++ test/core/storage_reactive_test.dart | 53 +++++ test/fg/flagsmith_analytics_test.dart | 10 +- test/fg/flagsmith_caches_test.dart | 28 +-- test/fg/flagsmith_exceptions_test.dart | 56 ++--- test/fg/flagsmith_init_test.dart | 14 +- test/fg/flagsmith_inmemory_test.dart | 55 +++-- test/fg/flagsmith_realtime_test.dart | 6 +- test/fg/flagsmith_streams_test.dart | 37 +-- test/fg/flagsmith_traits_test.dart | 46 ++-- test/shared.dart | 42 +++- test/storage/storage_test.dart | 100 ++++++++ 52 files changed, 2203 insertions(+), 340 deletions(-) create mode 100644 lib/src/core/crud_storage.dart create mode 100644 lib/src/core/extensions/converters.dart create mode 100644 lib/src/core/extensions/string_ext.dart create mode 100644 lib/src/core/model/feature.dart create mode 100644 lib/src/core/model/feature.g.dart create mode 100644 lib/src/core/model/flag.dart create mode 100644 lib/src/core/model/flag.g.dart create mode 100644 lib/src/core/model/flags_and_traits.dart create mode 100644 lib/src/core/model/flags_and_traits.g.dart create mode 100644 lib/src/core/model/identity.dart create mode 100644 lib/src/core/model/identity.g.dart create mode 100644 lib/src/core/model/index.dart create mode 100644 lib/src/core/model/loading.dart create mode 100644 lib/src/core/model/trait.dart create mode 100644 lib/src/core/model/trait.g.dart create mode 100644 lib/src/core/storage/in_memory_storage.dart create mode 100644 lib/src/core/storage_provider.dart create mode 100644 lib/src/core/storage_type.dart create mode 100644 lib/src/core/tools/security.dart create mode 100644 lib/src/storage/sharedpreferences_store.dart create mode 100644 lib/src/storage/storage.dart create mode 100644 test/core/flagsmith_core_test.dart create mode 100644 test/core/models/flag_test.dart create mode 100644 test/core/models/future_test.dart create mode 100644 test/core/models/identity_test.dart create mode 100644 test/core/models/trait_identity_test.dart create mode 100644 test/core/models/trait_test.dart create mode 100644 test/core/storage_reactive_test.dart create mode 100644 test/storage/storage_test.dart diff --git a/.github/workflows/publish_master.yml b/.github/workflows/publish_master.yml index 7cc4c5a..d8a57bd 100644 --- a/.github/workflows/publish_master.yml +++ b/.github/workflows/publish_master.yml @@ -4,40 +4,25 @@ on: tags: - "*" +env: + FLUTTER_VERSION: '3.x' + jobs: analyze: runs-on: ubuntu-latest name: Dart Analyze steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable - run: flutter pub get - run: flutter analyze # TODO https://github.com/Flagsmith/flagsmith-flutter-client/issues/57 - publishing: - runs-on: ubuntu-latest - container: - image: google/dart:latest - name: Dart Publish Package - needs: analyze - steps: - - uses: actions/checkout@v1 - - name: Setup credentials - run: | - mkdir -p ~/.pub-cache - cat < ~/.pub-cache/credentials.json - { - "accessToken":"${{ secrets.OAUTH_ACCESS_TOKEN }}", - "refreshToken":"${{ secrets.OAUTH_REFRESH_TOKEN }}", - "tokenEndpoint":"https://accounts.google.com/o/oauth2/token", - "scopes": [ "openid", "https://www.googleapis.com/auth/userinfo.email" ], - "expiration": 1649072931936 - } - EOF - - name: Publish package - run: pub publish -f + publish: + permissions: + id-token: write + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d822fb2..00ec874 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,16 +10,19 @@ on: - reopened - ready_for_review +env: + FLUTTER_VERSION: '3.x' + jobs: analyze: runs-on: ubuntu-latest name: Dart Analyze steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable - run: flutter pub get - run: flutter analyze @@ -27,14 +30,13 @@ jobs: runs-on: ubuntu-latest name: Flutter Test steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' - + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable - run: flutter pub get - - run: flutter pub test + - run: flutter test # TODO https://github.com/Flagsmith/flagsmith-flutter-client/issues/57 @@ -46,8 +48,6 @@ jobs: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' - + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable - run: flutter pub publish --dry-run - diff --git a/CHANGELOG.md b/CHANGELOG.md index ff72189..b775ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,19 @@ # Changelog +## [6.0.0] + +Breaking Changes: + +- Drop Flutter 2 support + +Features: + +- Support transient identities and traits + +Other: + +- Integrate `flagsmith_core` and `flagsmith_storage_sharedpreferences` +- Minor unit test improvements + ## [5.0.1] - Change the base url to https://edge.api.flagsmith.com/api/v1/ diff --git a/example/lib/di.dart b/example/lib/di.dart index af32f79..385db8d 100644 --- a/example/lib/di.dart +++ b/example/lib/di.dart @@ -1,5 +1,4 @@ import 'package:flagsmith/flagsmith.dart'; -import 'package:flagsmith_storage/flagsmith_storage_sharedpreferences.dart'; import 'package:get_it/get_it.dart'; import 'bloc/flag_bloc.dart'; diff --git a/example/lib/main.dart b/example/lib/main.dart index 1873316..876c0c6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -47,7 +47,6 @@ ThemeData darkTheme(BuildContext context) { secondary: Color(0xFFBFA6E9), secondaryContainer: Color(0xFF9D88C0), surface: Color(0xFF1a1c26), - background: Color(0xFF1a1c26), ), textTheme: GoogleFonts.varelaRoundTextTheme( ThemeData.dark().textTheme, diff --git a/example/lib/models/custom_storage.dart b/example/lib/models/custom_storage.dart index 09bab04..2efbe60 100644 --- a/example/lib/models/custom_storage.dart +++ b/example/lib/models/custom_storage.dart @@ -1,4 +1,4 @@ -import 'package:flagsmith_flutter_core/flagsmith_flutter_core.dart'; +import 'package:flagsmith/flagsmith.dart'; /// CustomInMemoryStore storage class CustomInMemoryStore extends CoreStorage { diff --git a/example/pubspec.lock b/example/pubspec.lock index 51f7def..a70a80b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.6.0" asn1lib: dependency: transitive description: name: asn1lib - sha256: "21afe4333076c02877d14f4a89df111e658a6d466cbfc802eb705eb91bd5adfd" + sha256: "6b151826fcc95ff246cd219a0bf4c753ea14f4081ad71c61939becf3aba27f70" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.5" async: dependency: transitive description: @@ -61,42 +61,50 @@ packages: dependency: "direct main" description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" convert: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" crypto: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" dio: dependency: transitive description: name: dio - sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 + sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" url: "https://pub.dev" source: hosted - version: "5.3.2" + version: "2.0.0" encrypt: dependency: transitive description: name: encrypt - sha256: "4fd4e4fdc21b9d7d4141823e1e6515cd94e7b8d84749504c232999fba25d9bbb" + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.3" equatable: dependency: "direct main" description: @@ -117,41 +125,25 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.3" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.1" flagsmith: dependency: "direct main" description: path: ".." relative: true source: path - version: "4.0.0" - flagsmith_flutter_core: - dependency: transitive - description: - name: flagsmith_flutter_core - sha256: "4e025cb80c7dbc31fbc4f0789e0fde6dd7e779fe6da328322afccdabc87d510e" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - flagsmith_storage: - dependency: "direct main" - description: - name: flagsmith_storage - sha256: "311a611b304f387090880d90fa0aba847646bb6d41b5928cb135a92e6c5eb5ed" - url: "https://pub.dev" - source: hosted - version: "4.0.0" + version: "5.0.1" flutter: dependency: "direct main" description: flutter @@ -203,10 +195,10 @@ packages: dependency: "direct main" description: name: get_it - sha256: f79870884de16d689cf9a7d15eedf31ed61d750e813c538a6efb92660fea83c3 + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 url: "https://pub.dev" source: hosted - version: "7.6.4" + version: "7.7.0" google_fonts: dependency: "direct main" description: @@ -243,18 +235,42 @@ packages: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.1" json_annotation: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: transitive description: @@ -267,26 +283,26 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.15.0" nested: dependency: transitive description: @@ -299,10 +315,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_drawing: dependency: transitive description: @@ -323,26 +339,26 @@ packages: dependency: transitive description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.4" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.12" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -355,122 +371,122 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" petitparser: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.2" platform: dependency: "direct main" description: name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.9.1" provider: dependency: transitive description: name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "6.1.2" rxdart: dependency: "direct main" description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" shared_preferences: dependency: transitive description: name: shared_preferences - sha256: "858aaa72d8f61637d64e776aca82e1c67e6d9ee07979123c5d17115031c1b13b" + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.5.3" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -488,26 +504,26 @@ packages: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" term_glyph: dependency: transitive description: @@ -520,10 +536,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.7.3" typed_data: dependency: transitive description: @@ -540,38 +556,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - web: + vm_service: dependency: transitive description: - name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b url: "https://pub.dev" source: hosted - version: "0.1.4-beta" - win32: + version: "14.3.0" + web: dependency: transitive description: - name: win32 - sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "5.0.7" + version: "1.1.0" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.1.0" xml: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.5.0" sdks: - dart: ">=3.1.0-185.0.dev <4.0.0" - flutter: ">=3.7.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index c161f8a..70e8eb6 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,7 +11,6 @@ dependencies: sdk: flutter flagsmith: path: ../ - flagsmith_storage: ^4.0.0 platform: ^3.1.0 collection: ^1.15.0 @@ -21,7 +20,7 @@ dependencies: get_it: ^7.2.0 google_fonts: ^2.3.1 hexcolor: ^2.0.6 - rxdart: ^0.27.5 + rxdart: ^0.28.0 dev_dependencies: flutter_lints: ^1.0.4 flutter_test: diff --git a/lib/flagsmith.dart b/lib/flagsmith.dart index 305cf53..9caf6f4 100644 --- a/lib/flagsmith.dart +++ b/lib/flagsmith.dart @@ -3,10 +3,9 @@ /// /// Flagsmith allows you to manage feature flags and remote config across multiple projects, environments and organisations. -library flagsmith; - -export 'package:flagsmith_flutter_core/flagsmith_flutter_core.dart'; +library; export 'src/flagsmith_client.dart'; export 'src/flagsmith_config.dart'; export 'src/core/core.dart'; +export 'src/storage/storage.dart'; diff --git a/lib/src/core/core.dart b/lib/src/core/core.dart index 51979e4..f9f43a5 100644 --- a/lib/src/core/core.dart +++ b/lib/src/core/core.dart @@ -1,5 +1,9 @@ +export 'crud_storage.dart'; +export 'datetime_x.dart'; export 'exceptions.dart'; - +export 'extensions/converters.dart'; +export 'model/index.dart'; +export 'storage_provider.dart'; +export 'storage_type.dart'; +export 'storage/in_memory_storage.dart'; export 'string_x.dart'; - -export 'datetime_x.dart'; diff --git a/lib/src/core/crud_storage.dart b/lib/src/core/crud_storage.dart new file mode 100644 index 0000000..06c394b --- /dev/null +++ b/lib/src/core/crud_storage.dart @@ -0,0 +1,16 @@ +/// Abstract for CRUD operations over storage +abstract class CoreStorage { + Future create(String key, String item); + Future read(String key); + Future update(String key, String item); + Future delete(String key); + Future clear(); + Future init(); + Future seed(List> items); + Future> getAll(); +} + +mixin SecureStorage { + Future getSecuredValue(String key); + Future setSecuredValue(String key, String value, {bool update = false}); +} diff --git a/lib/src/core/exceptions.dart b/lib/src/core/exceptions.dart index 2641abb..81b93be 100644 --- a/lib/src/core/exceptions.dart +++ b/lib/src/core/exceptions.dart @@ -38,5 +38,5 @@ class FlagsmithApiException extends DioException { /// FlagsmithConfigException /// - When client is misconfigured class FlagsmithConfigException extends FlagsmithException { - FlagsmithConfigException(Exception e) : super(e); + FlagsmithConfigException(Exception super.e); } diff --git a/lib/src/core/extensions/converters.dart b/lib/src/core/extensions/converters.dart new file mode 100644 index 0000000..8f3a2a5 --- /dev/null +++ b/lib/src/core/extensions/converters.dart @@ -0,0 +1,8 @@ +String? stringFromJson(dynamic value) => value == null ? null : '$value'; +String nonNullStringFromJson(dynamic value) => '$value'; +dynamic stringToJson(Object? value) { + if (value == null) { + return null; + } + return value; +} diff --git a/lib/src/core/extensions/string_ext.dart b/lib/src/core/extensions/string_ext.dart new file mode 100644 index 0000000..f2df9de --- /dev/null +++ b/lib/src/core/extensions/string_ext.dart @@ -0,0 +1,3 @@ +extension StringX on String { + String normalize() => toLowerCase().trim().replaceAll(' ', '_'); +} diff --git a/lib/src/core/model/feature.dart b/lib/src/core/model/feature.dart new file mode 100644 index 0000000..402065a --- /dev/null +++ b/lib/src/core/model/feature.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import '../extensions/converters.dart'; +import 'package:json_annotation/json_annotation.dart'; +part 'feature.g.dart'; + +/// Standard Flagsmith feature +@JsonSerializable() +class Feature { + final int? id; + final String name; + @JsonKey(name: 'created_date') + final DateTime? createdDate; + + @JsonKey( + name: 'initial_value', fromJson: stringFromJson, toJson: stringToJson) + final String? initialValue; + + @JsonKey(name: 'default_enabled') + final bool? defaultValue; + final String? description; + + Feature({ + this.id, + required this.name, + this.createdDate, + this.initialValue, + this.defaultValue, + this.description, + }); + factory Feature.named({ + int? id, + required String name, + DateTime? createdDate, + String? initialValue, + bool? defaultValue, + String? description, + }) => + Feature( + id: id, + name: name, + createdDate: createdDate, + initialValue: initialValue, + defaultValue: defaultValue, + description: description); + + factory Feature.fromJson(Map json) => + _$FeatureFromJson(json); + + Map toJson() => _$FeatureToJson(this); + String asString() => jsonEncode(toJson()); + Feature copyWith({ + int? id, + String? name, + DateTime? createdDate, + String? initialValue, + bool? defaultValue, + String? description, + }) => + Feature( + id: id ?? this.id, + name: name ?? this.name, + createdDate: createdDate ?? this.createdDate, + initialValue: initialValue ?? this.initialValue, + defaultValue: defaultValue ?? this.defaultValue, + description: description ?? this.description, + ); +} diff --git a/lib/src/core/model/feature.g.dart b/lib/src/core/model/feature.g.dart new file mode 100644 index 0000000..f6dcbdf --- /dev/null +++ b/lib/src/core/model/feature.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, non_constant_identifier_names, type_annotate_public_apis, omit_local_variable_types, unnecessary_this + +part of 'feature.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Feature _$FeatureFromJson(Map json) => Feature( + id: (json['id'] as num?)?.toInt(), + name: json['name'] as String, + createdDate: json['created_date'] == null + ? null + : DateTime.parse(json['created_date'] as String), + initialValue: stringFromJson(json['initial_value']), + defaultValue: json['default_enabled'] as bool?, + description: json['description'] as String?, + ); + +Map _$FeatureToJson(Feature instance) => { + 'id': instance.id, + 'name': instance.name, + 'created_date': instance.createdDate?.toIso8601String(), + 'initial_value': stringToJson(instance.initialValue), + 'default_enabled': instance.defaultValue, + 'description': instance.description, + }; diff --git a/lib/src/core/model/flag.dart b/lib/src/core/model/flag.dart new file mode 100644 index 0000000..9f15799 --- /dev/null +++ b/lib/src/core/model/flag.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import '../extensions/converters.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'dart:math'; + +import 'feature.dart'; +part 'flag.g.dart'; + +@JsonSerializable() +class Flag { + final int? id; + final Feature feature; + @JsonKey( + name: 'feature_state_value', + fromJson: stringFromJson, + toJson: stringToJson) + final String? stateValue; + final bool? enabled; + final int? environment; + final int? identity; + @JsonKey(name: 'feature_segment') + final int? featureSegment; + Flag( + {this.id, + required this.feature, + this.stateValue, + this.enabled, + this.environment, + this.identity, + this.featureSegment}); + + String get key => feature.name; + @override + String toString() { + return 'F(${feature.name}:$enabled)'; + } + + static int _generateNum(int min, int max) => + min + Random().nextInt(max - min); + factory Flag.named( + {int? id, + required Feature feature, + String? stateValue, + bool? enabled, + int? environment, + int? identity, + int? featureSegment}) => + Flag( + id: id, + feature: feature, + stateValue: stateValue, + enabled: enabled, + environment: environment, + identity: identity, + featureSegment: featureSegment, + ); + factory Flag.seed(String featureName, {bool enabled = true, String? value}) { + var id = _generateNum(1, 100); + + return Flag.named( + id: id, + stateValue: value, + feature: Feature.named( + id: id, + name: featureName, + createdDate: DateTime.now().add( + Duration(days: _generateNum(0, 10)), + ), + initialValue: '', + defaultValue: false, + ), + enabled: enabled); + } + + factory Flag.fromJson(Map json) => _$FlagFromJson(json); + + Map toJson() => _$FlagToJson(this); + String asString() => jsonEncode(toJson()); + + Flag copyWith( + {int? id, + Feature? feature, + String? stateValue, + bool? enabled, + int? environment, + int? identity, + int? featureSegment}) => + Flag( + id: id ?? this.id, + feature: feature ?? this.feature, + stateValue: stateValue ?? this.stateValue, + enabled: enabled ?? this.enabled, + environment: environment ?? this.environment, + identity: identity ?? this.identity, + featureSegment: featureSegment ?? this.featureSegment, + ); +} diff --git a/lib/src/core/model/flag.g.dart b/lib/src/core/model/flag.g.dart new file mode 100644 index 0000000..ddfecd9 --- /dev/null +++ b/lib/src/core/model/flag.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, non_constant_identifier_names, type_annotate_public_apis, omit_local_variable_types, unnecessary_this + +part of 'flag.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Flag _$FlagFromJson(Map json) => Flag( + id: (json['id'] as num?)?.toInt(), + feature: Feature.fromJson(json['feature'] as Map), + stateValue: stringFromJson(json['feature_state_value']), + enabled: json['enabled'] as bool?, + environment: (json['environment'] as num?)?.toInt(), + identity: (json['identity'] as num?)?.toInt(), + featureSegment: (json['feature_segment'] as num?)?.toInt(), + ); + +Map _$FlagToJson(Flag instance) => { + 'id': instance.id, + 'feature': instance.feature.toJson(), + 'feature_state_value': stringToJson(instance.stateValue), + 'enabled': instance.enabled, + 'environment': instance.environment, + 'identity': instance.identity, + 'feature_segment': instance.featureSegment, + }; diff --git a/lib/src/core/model/flags_and_traits.dart b/lib/src/core/model/flags_and_traits.dart new file mode 100644 index 0000000..c1ce041 --- /dev/null +++ b/lib/src/core/model/flags_and_traits.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; +import 'index.dart'; +part 'flags_and_traits.g.dart'; + +@JsonSerializable() +class FlagsAndTraits { + final List? flags; + final List? traits; + FlagsAndTraits({ + this.flags, + this.traits, + }); + factory FlagsAndTraits.fromJson(Map json) => + _$FlagsAndTraitsFromJson(json); + + Map toJson() => _$FlagsAndTraitsToJson(this); + String asString() => jsonEncode(toJson()); + FlagsAndTraits copyWith({ + List? flags, + List? traits, + }) => + FlagsAndTraits( + flags: flags ?? this.flags, + traits: traits ?? this.traits, + ); +} diff --git a/lib/src/core/model/flags_and_traits.g.dart b/lib/src/core/model/flags_and_traits.g.dart new file mode 100644 index 0000000..c454eef --- /dev/null +++ b/lib/src/core/model/flags_and_traits.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, non_constant_identifier_names, type_annotate_public_apis, omit_local_variable_types, unnecessary_this + +part of 'flags_and_traits.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FlagsAndTraits _$FlagsAndTraitsFromJson(Map json) => + FlagsAndTraits( + flags: (json['flags'] as List?) + ?.map((e) => Flag.fromJson(e as Map)) + .toList(), + traits: (json['traits'] as List?) + ?.map((e) => Trait.fromJson(e as Map)) + .toList(), + ); + +Map _$FlagsAndTraitsToJson(FlagsAndTraits instance) => + { + 'flags': instance.flags?.map((e) => e.toJson()).toList(), + 'traits': instance.traits?.map((e) => e.toJson()).toList(), + }; diff --git a/lib/src/core/model/identity.dart b/lib/src/core/model/identity.dart new file mode 100644 index 0000000..d793d0e --- /dev/null +++ b/lib/src/core/model/identity.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; +part 'identity.g.dart'; + +/// Personalized user +@JsonSerializable() +class Identity { + @JsonKey(includeIfNull: false) + final bool? transient; + final String identifier; + + const Identity({ + this.transient, + required this.identifier, + }); + + factory Identity.fromJson(Map json) => + _$IdentityFromJson(json); + + Map toJson() => _$IdentityToJson(this); + String asString() => jsonEncode(toJson()); + Identity copyWith({String? identifier, bool? transient}) => + Identity(identifier: identifier ?? this.identifier, transient: transient ?? this.transient); +} diff --git a/lib/src/core/model/identity.g.dart b/lib/src/core/model/identity.g.dart new file mode 100644 index 0000000..f9342ae --- /dev/null +++ b/lib/src/core/model/identity.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, non_constant_identifier_names, type_annotate_public_apis, omit_local_variable_types, unnecessary_this + +part of 'identity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Identity _$IdentityFromJson(Map json) => Identity( + transient: json['transient'] as bool?, + identifier: json['identifier'] as String, + ); + +Map _$IdentityToJson(Identity instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('transient', instance.transient); + val['identifier'] = instance.identifier; + return val; +} diff --git a/lib/src/core/model/index.dart b/lib/src/core/model/index.dart new file mode 100644 index 0000000..c9aaef4 --- /dev/null +++ b/lib/src/core/model/index.dart @@ -0,0 +1,8 @@ +library; + +export 'feature.dart'; +export 'identity.dart'; +export 'flag.dart'; +export 'flags_and_traits.dart'; +export 'trait.dart'; +export 'loading.dart'; diff --git a/lib/src/core/model/loading.dart b/lib/src/core/model/loading.dart new file mode 100644 index 0000000..3f316f3 --- /dev/null +++ b/lib/src/core/model/loading.dart @@ -0,0 +1 @@ +enum FlagsmithLoading { loading, loaded } diff --git a/lib/src/core/model/trait.dart b/lib/src/core/model/trait.dart new file mode 100644 index 0000000..1aaeb52 --- /dev/null +++ b/lib/src/core/model/trait.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +import 'identity.dart'; +part 'trait.g.dart'; + +@JsonSerializable() +class Trait { + final int? id; + @JsonKey(includeIfNull: false) + final bool? transient; + @JsonKey(name: 'trait_key') + final String key; + @JsonKey(name: 'trait_value', fromJson: _fromJson, toJson: _toJson) + final dynamic value; + + Trait({ + this.id, + this.transient, + required this.key, + required this.value, + }); + + factory Trait.fromJson(Map json) => _$TraitFromJson(json); + + Map toJson() => _$TraitToJson(this); + + String asString() => jsonEncode(toJson()); + + Trait copyWith({ + int? id, + String? key, + dynamic value, + }) => + Trait( + id: id ?? this.id, + key: key ?? this.key, + value: value ?? this.value, + ); + + static dynamic _fromJson(dynamic jsonValue) { + if (jsonValue is num) { + if (jsonValue % 1 == 0) { + return jsonValue.toInt(); // Treat as int if it's a whole number + } else { + return jsonValue.toDouble(); // Treat as double if it's a decimal + } + } else if (jsonValue is String || jsonValue is bool) { + return jsonValue; + } + throw ArgumentError('Invalid value type'); + } + + static dynamic _toJson(dynamic value) { + if (value is double || value is int || value is String || value is bool) { + return value; + } + throw ArgumentError('Invalid value type'); + } +} + +@JsonSerializable() +class TraitWithIdentity { + final Identity identity; + @JsonKey(name: 'trait_key') + final String key; + @JsonKey( + name: 'trait_value', + fromJson: _fromJson, + toJson: _toJson, + ) + final dynamic value; + + TraitWithIdentity({ + required this.identity, + required this.key, + required this.value, + }); + + factory TraitWithIdentity.fromJson(Map json) => + _$TraitWithIdentityFromJson(json); + + Map toJson() => _$TraitWithIdentityToJson(this); + + String asString() => jsonEncode(toJson()); + + TraitWithIdentity copyWith({ + Identity? identity, + String? key, + dynamic value, + }) => + TraitWithIdentity( + identity: identity ?? this.identity, + key: key ?? this.key, + value: value ?? this.value, + ); + + static dynamic _fromJson(dynamic jsonValue) { + if (jsonValue is num) { + if (jsonValue % 1 == 0) { + return jsonValue.toInt(); // Treat as int if it's a whole number + } else { + return jsonValue.toDouble(); // Treat as double if it's a decimal + } + } else if (jsonValue is String || jsonValue is bool) { + return jsonValue; + } + throw ArgumentError('Invalid value type'); + } + + static dynamic _toJson(dynamic value) { + if (value is double || value is int || value is String || value is bool) { + return value; + } + throw ArgumentError('Invalid value type'); + } +} diff --git a/lib/src/core/model/trait.g.dart b/lib/src/core/model/trait.g.dart new file mode 100644 index 0000000..530464a --- /dev/null +++ b/lib/src/core/model/trait.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, non_constant_identifier_names, type_annotate_public_apis, omit_local_variable_types, unnecessary_this + +part of 'trait.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Trait _$TraitFromJson(Map json) => Trait( + id: (json['id'] as num?)?.toInt(), + transient: json['transient'] as bool?, + key: json['trait_key'] as String, + value: Trait._fromJson(json['trait_value']), + ); + +Map _$TraitToJson(Trait instance) { + final val = { + 'id': instance.id, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('transient', instance.transient); + val['trait_key'] = instance.key; + val['trait_value'] = Trait._toJson(instance.value); + return val; +} + +TraitWithIdentity _$TraitWithIdentityFromJson(Map json) => + TraitWithIdentity( + identity: Identity.fromJson(json['identity'] as Map), + key: json['trait_key'] as String, + value: TraitWithIdentity._fromJson(json['trait_value']), + ); + +Map _$TraitWithIdentityToJson(TraitWithIdentity instance) => + { + 'identity': instance.identity.toJson(), + 'trait_key': instance.key, + 'trait_value': TraitWithIdentity._toJson(instance.value), + }; diff --git a/lib/src/core/storage/in_memory_storage.dart b/lib/src/core/storage/in_memory_storage.dart new file mode 100644 index 0000000..bae25aa --- /dev/null +++ b/lib/src/core/storage/in_memory_storage.dart @@ -0,0 +1,84 @@ +import '../crud_storage.dart'; + +/// InMemoryStore storage +class InMemoryStorage extends CoreStorage { + final Map _items = {}; + + InMemoryStorage() { + init(); + } + + @override + Future init() async { + return Future.value(null); + } + + /// Clear items + @override + Future clear() async { + _items.clear(); + return true; + } + + /// save [item] in [key] if missing + @override + Future create(String key, String item) async { + if (!_items.containsKey(key)) { + _items[key] = item; + return true; + } + return false; + } + + /// delete [item] + @override + Future delete(String key) async { + if (_items.containsKey(key)) { + _items.remove(key); + return true; + } + return false; + } + + /// read saved by [id] + /// Retruns [Flag] or [null] + @override + Future read(String key) async { + if (_items.containsKey(key)) { + return Future.value(_items[key]); + } + return null; + } + + /// update or create [item] + @override + Future update(String key, String item) async { + if (_items.containsKey(key)) { + _items[key] = item; + return true; + } + return false; + } + + /// regturns all saved flags [List] + @override + Future> getAll() async { + var result = []; + _items.forEach((key, String value) { + result.add(value); + }); + return result.toList(); + } + + @override + Future seed(List>? items) async { + var saved = await getAll(); + if (saved.isEmpty && items != null && items.isNotEmpty == true) { + for (var item in items) { + await create(item.key, item.value); + } + return true; + } + return false; + } +} diff --git a/lib/src/core/storage_provider.dart b/lib/src/core/storage_provider.dart new file mode 100644 index 0000000..065d6bc --- /dev/null +++ b/lib/src/core/storage_provider.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:rxdart/rxdart.dart'; + +import 'crud_storage.dart'; +import 'model/flag.dart'; +import 'tools/security.dart'; + +class StorageProvider with SecureStorage { + Map?> _streams = {}; + late StorageSecurity _storageSecurity; + final CoreStorage _storage; + final bool logEnabled; + void log(message) { + if (logEnabled) { + // ignore: avoid_print + print(message); + } + } + + StorageProvider(this._storage, {String? password, this.logEnabled = false}) { + assert(password != null); + _storageSecurity = StorageSecurity(password); + _storage.init(); + _initSubjects(); + } + + @override + Future getSecuredValue(String key) async { + var item = await _storage.read(key); + if (item == null) { + return null; + } + var decrypted = _storageSecurity.decrypt(item); + return decrypted; + } + + @override + Future setSecuredValue(String key, String value, + {bool update = false}) async { + var encrypted = _storageSecurity.encrypt(value); + if (update) { + return await _storage.update(key, encrypted); + } + return await _storage.create(key, encrypted); + } + + Future create(String key, Flag item) async { + var response = await setSecuredValue(key, item.asString()); + _createSubject(await read(key)); + return response; + } + + Future delete(String key) { + _destroySubject(key); + _streams.remove(key); + + return _storage.delete(key); + } + + Future read(String key) async { + var decrypted = await getSecuredValue(key); + if (decrypted == null) { + return null; + } + return Flag.fromJson(jsonDecode(decrypted) as Map); + } + + Future update(String key, Flag item) async { + var result = await setSecuredValue(key, item.asString(), update: true); + _updateSubject(await read(key)); + return result; + } + + Future clear() async { + _clearSubjects(); + await _storage.clear(); + return true; + } + + Future> getAll() async { + var list = await _storage.getAll(); + return list.map((item) { + var decrypted = _storageSecurity.decrypt(item!)!; + return Flag.fromJson(jsonDecode(decrypted) as Map); + }).toList(); + } + + Future saveAll(List items) async { + for (var item in items) { + final current = await read(item.key); + if (current != null) { + await update(item.key, item); + } else { + await create(item.key, item); + } + } + return true; + } + + Future seed({required List items}) async { + var list = items + .map((e) => MapEntry(e.key, _storageSecurity.encrypt(e.asString()))) + .toList(); + var result = await _storage.seed(list); + if (result) { + for (var item in items) { + _createSubject(item); + } + } + return result; + } + + Stream? stream(String featureName) => _streams[featureName]?.stream; + + BehaviorSubject? subject(String featureName) => _streams[featureName]; + + Future _initSubjects() async { + var result = await getAll(); + for (var flag in result) { + _createSubject(flag); + } + } + + void _createSubject(Flag? item) { + if (item == null) { + return; + } + + if (_streams[item.key] == null) { + _streams[item.key] = BehaviorSubject.seeded(item); + log('_createSubject ${item.key} -> ${_streams[item.key]?.value}'); + } + } + + void _updateSubject(Flag? item) { + if (item == null) { + return; + } + _streams[item.key]?.add(item); + log('_updateSubject ${item.key} -> ${_streams[item.key]?.value.enabled} f: ${item.enabled}'); + } + + void _destroySubject(String featureName) { + try { + _streams[featureName]?.close(); + _streams[featureName] = null; + } catch (e) { + log(e.toString()); + } + } + + void _clearSubjects() { + for (var item in _streams.entries) { + _destroySubject(item.key); + } + _streams = {}; + } + + Future togggleFeature(String featureName) async { + var value = await read(featureName); + if (value == null) { + return false; + } + var current = value.enabled!; + var updated = value.copyWith(enabled: !current); + return await update(featureName, updated); + } +} diff --git a/lib/src/core/storage_type.dart b/lib/src/core/storage_type.dart new file mode 100644 index 0000000..9bae0bd --- /dev/null +++ b/lib/src/core/storage_type.dart @@ -0,0 +1,2 @@ +/// [StorageType] defining type of storage used by instance of [FlagsmithClient] +enum StorageType { inMemory, custom } diff --git a/lib/src/core/tools/security.dart b/lib/src/core/tools/security.dart new file mode 100644 index 0000000..9e4966b --- /dev/null +++ b/lib/src/core/tools/security.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart'; + +class StorageSecurity { + final _random = Random.secure(); + final String? password; + late Uint8List _encryptedPassword; + late Encrypter _enc; + StorageSecurity(this.password) { + _encryptedPassword = _generateEncryptPassword(password!); + _enc = Encrypter(Salsa20(Key(_encryptedPassword))); + } + Uint8List _generateEncryptPassword(String password) { + var blob = Uint8List.fromList(md5.convert(utf8.encode(password)).bytes); + assert(blob.length == 16); + return blob; + } + + Uint8List _randBytes(int length) { + return Uint8List.fromList( + List.generate(length, (i) => _random.nextInt(256))); + } + + String encrypt(String value) { + final iv = _randBytes(8); + final ivEncoded = base64.encode(iv); + assert(ivEncoded.length == 12); + final encoded = _enc.encrypt(json.encode(value), iv: IV(iv)).base64; + return '$ivEncoded$encoded'; + } + + String? decrypt(String value) { + assert(value.length >= 12); + final iv = base64.decode(value.substring(0, 12)); + + // Extract the real input + value = value.substring(12); + + // Decode the input + return json.decode(_enc.decrypt64(value, iv: IV(iv))) as String?; + } +} diff --git a/lib/src/flagsmith_client.dart b/lib/src/flagsmith_client.dart index 3d2a704..b7e1b68 100644 --- a/lib/src/flagsmith_client.dart +++ b/lib/src/flagsmith_client.dart @@ -209,8 +209,8 @@ class FlagsmithClient { await storageProvider.clear(); } await storageProvider.seed(items: seeds); - final _items = await storageProvider.getAll(); - _updateCaches(list: _items); + final items = await storageProvider.getAll(); + _updateCaches(list: items); return true; } @@ -244,9 +244,11 @@ class FlagsmithClient { /// Get a list of existing Features for the given environment and user /// /// [user] a user in context + /// [traits] a list of user traits + /// [reload] force reload from API /// Returns a list of feature flags Future> getFeatureFlags( - {Identity? user, bool reload = true}) async { + {Identity? user, List? traits, bool reload = true}) async { if (!reload) { var result = await storageProvider.getAll(); if (result.isNotEmpty) { @@ -256,7 +258,8 @@ class FlagsmithClient { return result; } _loading.add(FlagsmithLoading.loading); - final list = user == null ? await _getFlags() : await _getUserFlags(user); + final list = + user == null ? await _getFlags() : await _getUserFlags(user, traits); _loading.add(FlagsmithLoading.loaded); return list; } @@ -376,9 +379,9 @@ class FlagsmithClient { .toList(); await storageProvider.saveAll(list); - final _saved = await storageProvider.getAll() + final saved = await storageProvider.getAll() ..sort((a, b) => a.feature.name.compareTo(b.feature.name)); - _updateCaches(list: _saved); + _updateCaches(list: saved); return list; } @@ -393,12 +396,15 @@ class FlagsmithClient { } // Internal list of [user] flags - Future> _getUserFlags(Identity user) async { + Future> _getUserFlags(Identity user, List? traits) async { try { cachedUser = user; - var params = {'identifier': user.identifier}; - var response = await _api.get?>(config.identitiesURI, - queryParameters: params); + var identityData = user.toJson(); + if (traits != null && traits.isNotEmpty) { + identityData['traits'] = traits.map((t) => t.toJson()); + } + + var response = await _api.post(config.identitiesURI, data: identityData); if (response.statusCode == 200) { lastGetFlags = DateTime.now().secondsSinceEpoch; @@ -413,9 +419,9 @@ class FlagsmithClient { } await storageProvider.saveAll(data); - final _saved = await storageProvider.getAll() + final saved = await storageProvider.getAll() ..sort((a, b) => a.feature.name.compareTo(b.feature.name)); - _updateCaches(list: _saved); + _updateCaches(list: saved); return data; } @@ -445,6 +451,9 @@ class FlagsmithClient { try { cachedUser = user; var params = {'identifier': user.identifier}; + if (user.transient ?? false) { + params['transient'] = 'true'; + } var response = await _api.get?>(config.identitiesURI, queryParameters: params); @@ -510,9 +519,9 @@ class FlagsmithClient { if (response.data == null || response.data['traits'] == null) { return null; } - final _data = List>.from( + final responseData = List>.from( response.data['traits'] as List); - return _data + return responseData .map((e) => TraitWithIdentity( identity: Identity(identifier: identifier), key: e['trait_key'] as String, @@ -563,14 +572,14 @@ class FlagsmithClient { /// test toggle feature /// Future testToggle(String featureName) async { - final _result = await storageProvider.togggleFeature(featureName); - final _value = await storageProvider.read(featureName); + final result = await storageProvider.togggleFeature(featureName); + final value = await storageProvider.read(featureName); if (config.caches) { _flags.removeWhere((element) => element.feature.name == featureName); - if (_value != null) { - _flags.add(_value); + if (value != null) { + _flags.add(value); } } - return _result; + return result; } } diff --git a/lib/src/storage/sharedpreferences_store.dart b/lib/src/storage/sharedpreferences_store.dart new file mode 100644 index 0000000..a128cdb --- /dev/null +++ b/lib/src/storage/sharedpreferences_store.dart @@ -0,0 +1,85 @@ +import '../core/core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class FlagsmithSharedPreferenceStore extends CoreStorage { + SharedPreferences? _prefs; + + FlagsmithSharedPreferenceStore() { + init(); + } + + Future containsKey(String key) async { + await init(); + return Future.value(_prefs!.containsKey(key)); + } + + @override + Future init() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + @override + Future clear() async { + await init(); + return _prefs!.clear(); + } + + @override + Future create(String key, String item) async { + if (!await containsKey(key)) { + return await _prefs!.setString(key, item); + } + return false; + } + + @override + Future read(String key) async { + if (await containsKey(key)) { + return _prefs!.getString(key); + } + return null; + } + + @override + Future delete(String key) async { + if (await containsKey(key)) { + return await _prefs!.remove(key); + } + return false; + } + + @override + Future> getAll() async { + await init(); + var items = []; + var keys = _prefs!.getKeys(); + for (var key in keys) { + var item = await read(key); + if (item != null) { + items.add(item); + } + } + return items; + } + + @override + Future seed(List>? items) async { + await init(); + var saved = await getAll(); + if (saved.isEmpty && items != null && items.isNotEmpty == true) { + for (var item in items) { + await create(item.key, item.value); + } + return true; + } + return false; + } + + @override + Future update(String key, String item) async { + if (await containsKey(key)) { + return await _prefs!.setString(key, item); + } + return false; + } +} diff --git a/lib/src/storage/storage.dart b/lib/src/storage/storage.dart new file mode 100644 index 0000000..539fb1a --- /dev/null +++ b/lib/src/storage/storage.dart @@ -0,0 +1 @@ +export 'sharedpreferences_store.dart'; diff --git a/pubspec.lock b/pubspec.lock index 1cdac1d..2af4d22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -26,10 +26,10 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" asn1lib: dependency: transitive description: @@ -114,10 +114,10 @@ packages: dependency: transitive description: name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" coverage: dependency: transitive description: @@ -127,13 +127,13 @@ packages: source: hosted version: "1.9.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto - sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" dart_style: dependency: transitive description: @@ -159,13 +159,29 @@ packages: source: hosted version: "2.0.0" encrypt: - dependency: transitive + dependency: "direct main" description: name: encrypt sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted version: "5.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: @@ -178,20 +194,12 @@ packages: dependency: transitive description: name: fixnum - sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "1.1.0" - flagsmith_flutter_core: - dependency: "direct main" - description: - name: flagsmith_flutter_core - sha256: "4e025cb80c7dbc31fbc4f0789e0fde6dd7e779fe6da328322afccdabc87d510e" - url: "https://pub.dev" - source: hosted - version: "3.0.0" + version: "1.1.1" flutter: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" @@ -203,6 +211,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" frontend_server_client: dependency: transitive description: @@ -268,13 +286,37 @@ packages: source: hosted version: "0.7.1" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + url: "https://pub.dev" + source: hosted + version: "10.0.5" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + url: "https://pub.dev" + source: hosted + version: "3.0.5" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" lints: dependency: "direct dev" description: @@ -371,6 +413,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pointycastle: dependency: transitive description: @@ -399,10 +481,66 @@ packages: dependency: "direct main" description: name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "0.27.7" + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -600,6 +738,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" yaml: dependency: transitive description: @@ -610,4 +756,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.5.0 <4.0.0" - flutter: ">=1.17.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3f3b428..7e99b86 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,22 +3,29 @@ description: >- Flutter Client SDK for https://www.flagsmith.com/, Flagsmith is 100% Open Source. Host yourself or let us take care of the hosting. -version: 5.0.1 +version: 6.0.0 homepage: https://github.com/Flagsmith/flagsmith-flutter-client repository: https://github.com/Flagsmith/flagsmith-flutter-client issue_tracker: https://github.com/Flagsmith/flagsmith-flutter-client/issues environment: - sdk: '>=2.13.0 <3.0.0' + sdk: ">=3.0.0 <4.0.0" dependencies: - flagsmith_flutter_core: ^3.0.0 + flutter: + sdk: flutter flutter_client_sse: ^2.0.0 - collection: ^1.17.0 + collection: ^1.18.0 dio: ^5.3.2 - rxdart: ^0.27.7 + rxdart: ^0.28.0 + shared_preferences: ^2.3.2 + json_annotation: ^4.9.0 + encrypt: ^5.0.3 + crypto: ^3.0.6 dev_dependencies: test: ^1.24.6 + flutter_test: + sdk: flutter lints: ^5.0.0 http_mock_adapter: ^0.6.1 mockito: ^5.4.2 diff --git a/test/core/flagsmith_core_test.dart b/test/core/flagsmith_core_test.dart new file mode 100644 index 0000000..dd38597 --- /dev/null +++ b/test/core/flagsmith_core_test.dart @@ -0,0 +1,97 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'package:collection/collection.dart'; +import 'package:test/test.dart'; + +import '../shared.dart'; + +void main() { + final InMemoryStorage storage = InMemoryStorage(); + late StorageProvider storageProvider; + + setUpAll(() { + storageProvider = + StorageProvider(storage, password: 'pa5w0rD', logEnabled: true); + }); + + test('adds one to input values', () async { + final response = await storageProvider.seed(items: seeds); + expect(response, true); + final all = await storageProvider.getAll(); + expect(all.length, seeds.length); + }); + test('When update value', () async { + final response = await storageProvider.read(myFeatureName); + expect(response, isNotNull); + expect(response!.enabled, isTrue); + }); + test('Update with enabled false', () async { + await storageProvider.update( + myFeatureName, Flag.seed(myFeatureName, enabled: false)); + final responseUpdated = await storageProvider.read(myFeatureName); + expect(responseUpdated, isNotNull); + expect(responseUpdated!.enabled, isFalse); + }); + test('Remove item from storage', () async { + await storageProvider.delete(myFeatureName); + final items = await storageProvider.getAll(); + expect(items, isNotEmpty); + expect(items.length, seeds.length - 1); + }); + test('Remove item from storage', () async { + await storageProvider.clear(); + final items = await storageProvider.getAll(); + expect(items, isEmpty); + }); + test('Create a flag', () async { + final created = await storageProvider.create( + 'test_feature', Flag.seed('test_feature', enabled: false)); + expect(created, isTrue); + }); + test('Save all flags', () async { + final created = await storageProvider.saveAll([ + Flag.seed('test_feature_a', enabled: false), + Flag.seed('test_feature_b', enabled: true) + ]); + expect(created, isTrue); + final all = await storageProvider.getAll(); + expect(all, isNotEmpty); + expect( + all, + const TypeMatcher>().having( + (p0) => p0 + .firstWhereOrNull((element) => element.key == 'test_feature_a'), + 'saved flags containse feature flag `test_feature_a`', + isNotNull)); + }); + + test('Update all flags', () async { + final created = await storageProvider.saveAll([ + Flag.seed('test_feature_a', enabled: false), + Flag.seed('test_feature_b', enabled: true) + ]); + expect(created, isTrue); + final all = await storageProvider.getAll(); + expect(all, isNotEmpty); + expect( + all, + const TypeMatcher>().having( + (p0) => p0 + .firstWhereOrNull((element) => element.key == 'test_feature_a'), + 'saved flags containse feature flag `test_feature_a`', + isNotNull)); + }); + + test('Init storage over', () async { + storageProvider = + StorageProvider(storage, password: 'pa5w0rD', logEnabled: true); + await storageProvider.clear(); + expect(await storageProvider.seed(items: seeds), isTrue); + expect(await storageProvider.seed(items: seeds), isFalse); + }); + + test('Init storage missing passowrd', () { + expect(() { + StorageProvider(storage, password: null, logEnabled: true); + }, throwsA(isA())); + }); +} diff --git a/test/core/models/flag_test.dart b/test/core/models/flag_test.dart new file mode 100644 index 0000000..65ed07d --- /dev/null +++ b/test/core/models/flag_test.dart @@ -0,0 +1,115 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'dart:convert'; +import 'package:test/test.dart'; + +import '../../shared.dart'; + +void main() { + group('[Flag]', () { + late String testValue, featureValue; + setUp(() { + featureValue = r'''{ + "id": 2, + "name": "font_size", + "created_date": "2018-06-04T12:51:18.646762Z", + "initial_value": 10, + "description": "test description", + "type": "STANDARD", + "project": 2 + }'''; + testValue = '''{ + "id": 2, + "feature": { + "id": 2, + "name": "font_size", + "created_date": "2018-06-04T12:51:18.646762Z", + "initial_value": 10, + "description": "test description", + "type": "STANDARD", + "project": 2 + }, + "feature_state_value": "10.1.0", + "enabled": true, + "environment": 2, + "identity": 1 + } + '''; + }); + test('When flag is not empty, test values', () { + var flag = Flag.fromJson(jsonDecode(testValue) as Map); + expect(flag.stateValue, isNotNull); + expect(flag.enabled, true); + expect(flag.feature, isNotNull); + expect(flag.feature.name, isNotNull); + expect(flag.feature.description, isNotNull); + }); + test('When feature successfuly parsed', () async { + final feature = + Feature.fromJson(jsonDecode(featureValue) as Map); + expect(feature, isNotNull); + expect(feature.id, 2); + }); + + test('When flag successfuly parsed', () { + var flag = Flag.fromJson(jsonDecode(testValue) as Map); + final flag0 = flag.asString(); + expect(flag0, isA()); + expect(flag0, isNotNull); + expect(flag0, isNotEmpty); + }); + + test('When flag value successfuly updated', () { + var flag = Flag.fromJson(jsonDecode(testValue) as Map); + final feature = flag.feature.copyWith(initialValue: '12'); + final flag0 = flag.copyWith(feature: feature); + + expect(flag.feature.initialValue, '10'); + expect(flag0.feature.initialValue, '12'); + expect(flag.feature.initialValue, isNot(flag0.feature.initialValue)); + }); + + test('When flag seed state is enabled', () { + var flagDefault = Flag.seed('feature'); + + expect(flagDefault.enabled, true); + expect(flagDefault.feature, isNotNull); + + var flag = Flag.seed('feature'); + + expect(flag.enabled, true); + expect(flag.feature, isNotNull); + }); + test('When flag seed state is disabled', () { + var flag = Flag.seed('feature', enabled: false); + expect(flag.enabled, false); + expect(flag.feature, isNotNull); + }); + test('When flag seed type is cofig', () { + var flag = Flag.seed('feature', enabled: false, value: '1.0.0'); + expect(flag.enabled, false); + expect(flag.feature, isNotNull); + expect(flag.stateValue, isNotNull); + expect(flag.stateValue, '1.0.0'); + }); + }); + + group('[FlagAndTraits]', () { + test('When response successfuly parsed', () { + final identity = FlagsAndTraits.fromJson( + jsonDecode(identitiesResponseData) as Map); + expect(identity.flags, isNotEmpty); + expect(identity.traits, isNotEmpty); + + final converted = identity.toJson(); + expect(converted, isNotNull); + expect(converted, isNotEmpty); + + final copiedIdentity = identity.copyWith(flags: [], traits: []); + expect( + copiedIdentity, + const TypeMatcher() + .having((e) => e.flags, 'flags are empty', isEmpty) + .having((e) => e.traits, 'traits are empty', isEmpty)); + }); + }); +} diff --git a/test/core/models/future_test.dart b/test/core/models/future_test.dart new file mode 100644 index 0000000..c62d9a6 --- /dev/null +++ b/test/core/models/future_test.dart @@ -0,0 +1,18 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'package:test/test.dart'; + +void main() { + group('[Futures]', () { + test('When value is normalized then success', () { + var testValue = 'test String'; + var normalized = testValue.normalize(); + expect(normalized, 'test_string'); + }); + + test('When value was not trimed, then normalize', () { + var testValue = ' _testStri ng'; + var normalized = testValue.normalize(); + expect(normalized, '_teststri_ng'); + }); + }); +} diff --git a/test/core/models/identity_test.dart b/test/core/models/identity_test.dart new file mode 100644 index 0000000..214aa9d --- /dev/null +++ b/test/core/models/identity_test.dart @@ -0,0 +1,46 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'dart:convert'; +import 'package:test/test.dart'; + +void main() { + const identityId = '123-456-789'; + const identityJson = r'''{"identifier":"123-456-789"}'''; + const transientIdentityJson = + r'''{"identifier":"123-456-789","transient":true}'''; + final decodedIdentityJson = jsonDecode(identityJson) as Map; + final decodedTransientIdentityJson = + jsonDecode(transientIdentityJson) as Map; + // final malformedIdentityJson = '''{"identifier_bad_guy":"$identityId"}'''; + group('[Identity]', () { + test('When identity response converted then success', () { + final identity = Identity.fromJson(decodedIdentityJson); + expect(identity.identifier, identityId); + }); + test('When transient identity response converted then success', () { + final identity = Identity.fromJson(decodedTransientIdentityJson); + expect(identity.identifier, identityId); + expect(identity.transient, true); + }); + }); + + group('[Identity] - copyWith', () { + test('When identity updated then success', () { + final identity = Identity.fromJson(decodedIdentityJson); + expect(identity.identifier, identityId); + + final updated = identity.copyWith(identifier: 'newOne', transient: true); + expect(updated.identifier, 'newOne'); + expect(updated.transient, true); + expect(identity.hashCode, isNot(updated.hashCode)); + }); + test('When identity not updated then success', () { + final identity = Identity.fromJson(decodedIdentityJson); + expect(identity.identifier, identityId); + + final updated = identity.copyWith(); + + expect(identity.identifier, identityId); + expect(identity, isNot(updated)); + }); + }); +} diff --git a/test/core/models/trait_identity_test.dart b/test/core/models/trait_identity_test.dart new file mode 100644 index 0000000..5410992 --- /dev/null +++ b/test/core/models/trait_identity_test.dart @@ -0,0 +1,96 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'dart:convert'; +import 'package:test/test.dart'; + +void main() { + const identityId = '123-456-789'; + const traitStringValue = '''{ + "identity": { + "identifier": "$identityId" + }, + "trait_key": "trait_key", + "trait_value": "value" + }'''; + final decodedTraitStringValue = + jsonDecode(traitStringValue) as Map; + const traitNotStringValue = '''{ + "identity": { + "identifier": "$identityId" + }, + "trait_key": "trait_key", + "trait_value": true + }'''; + final decodedTraitNotStringValue = + jsonDecode(traitNotStringValue) as Map; + group('[TraitWithIdentity] - basic tests', () { + test('When trait with identity parsed then success', () { + final trait = TraitWithIdentity.fromJson(decodedTraitStringValue); + expect(trait.identity.identifier, identityId); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + }); + + test('When trait with identity parsed with non string, then success', () { + final trait = TraitWithIdentity.fromJson(decodedTraitNotStringValue); + expect(trait.identity.identifier, identityId); + expect(trait.key, 'trait_key'); + expect(trait.value, true); + }); + }); + + group('[TraitWithIdentity] - converting', () { + test('When trait with identity converted, then success', () { + final trait = TraitWithIdentity.fromJson(decodedTraitStringValue); + + expect(trait.identity.identifier, identityId); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + + final mapped = trait.asString(); + expect(mapped, isNotNull); + expect(mapped.runtimeType, String); + }); + test('When trait with identity converted to Map', () { + final trait = TraitWithIdentity.fromJson(decodedTraitStringValue); + + expect(trait.identity.identifier, identityId); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + + final mapped = trait.toJson(); + expect(mapped, isNotNull); + expect(mapped['identity']['identifier'], identityId); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], 'value'); + }); + }); + group('[TraitWithIdentity] - copyWith', () { + test('When trait with identity updated then success', () { + final trait = TraitWithIdentity.fromJson(decodedTraitStringValue); + + expect(trait.identity.identifier, identityId); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + final updated = trait.copyWith( + identity: const Identity(identifier: '13'), + key: 'trait_key2', + value: 'value2'); + + expect(updated.identity.identifier, '13'); + expect(updated.key, 'trait_key2'); + expect(updated.value, 'value2'); + }); + test('When trait with identity not updated then success', () { + final trait = TraitWithIdentity.fromJson(decodedTraitStringValue); + + expect(trait.identity.identifier, identityId); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + final updated = trait.copyWith(); + + expect(trait.identity.identifier, identityId); + expect(updated.key, 'trait_key'); + expect(updated.value, 'value'); + }); + }); +} diff --git a/test/core/models/trait_test.dart b/test/core/models/trait_test.dart new file mode 100644 index 0000000..31d5400 --- /dev/null +++ b/test/core/models/trait_test.dart @@ -0,0 +1,222 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'dart:convert'; +import 'package:test/test.dart'; + +void main() { + const traitStringValue = '''{ + "id": 12, + "trait_key": "trait_key", + "trait_value": "value" + }'''; + final decodedTraitStringValue = + jsonDecode(traitStringValue) as Map; + + const traitBoolValue = '''{ + "id": 12, + "trait_key": "trait_key", + "trait_value": true + }'''; + final decodedTraitBoolValue = + jsonDecode(traitBoolValue) as Map; + const traitIntValue = '''{ + "id": 12, + "trait_key": "trait_key", + "trait_value": 1 + }'''; + final decodedTraitIntValue = + jsonDecode(traitIntValue) as Map; + const traitDoubleValue = '''{ + "id": 12, + "trait_key": "trait_key", + "trait_value": 10.1 + }'''; + final decodedTraitDoubleValue = + jsonDecode(traitDoubleValue) as Map; + const transientTraitValue = '''{ + "id": 12, + "trait_key": "trait_key", + "trait_value": "transient", + "transient": true + }'''; + final decodedTransientTraitValue = + jsonDecode(transientTraitValue) as Map; + + group('[Trait] - basic tests', () { + test('When trait parsed then success', () { + final trait = Trait.fromJson(decodedTraitStringValue); + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + }); + + test('When trait parsed with bool value then success', () { + final trait = Trait.fromJson(decodedTraitBoolValue); + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, true); + }); + test('When trait parsed with int value then success', () { + final trait = Trait.fromJson(decodedTraitIntValue); + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 1); + }); + + test('When trait parsed with double value then success', () { + final trait = Trait.fromJson(decodedTransientTraitValue); + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 'transient'); + expect(trait.transient, true); + }); + + test('When transient trait parsed then success', () { + final trait = Trait.fromJson(decodedTraitDoubleValue); + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 10.1); + }); + }); + + group('[Trait] - converting', () { + test('When trait parsed & converted then success', () { + final trait = Trait.fromJson(decodedTraitStringValue); + + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + + final mapped = trait.asString(); + expect(mapped, isNotNull); + expect(mapped.runtimeType, String); + }); + test('When trait converted to Map then success ', () { + final trait = Trait.fromJson(decodedTraitStringValue); + + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + + final mapped = trait.toJson(); + expect(mapped, isNotNull); + expect(mapped['id'], 12); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], 'value'); + }); + }); + group('[Trait] - copyWith', () { + test('as a developer want to use with new data', () { + final trait = Trait.fromJson(decodedTraitStringValue); + + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + final updated = + trait.copyWith(id: 13, key: 'trait_key2', value: 'value2'); + + expect(updated.id, 13); + expect(updated.key, 'trait_key2'); + expect(updated.value, 'value2'); + }); + test('as a developer want to use copyWith same data', () { + final trait = Trait.fromJson(decodedTraitStringValue); + + expect(trait.id, 12); + expect(trait.key, 'trait_key'); + expect(trait.value, 'value'); + final updated = trait.copyWith(); + + expect(updated.id, 12); + expect(updated.key, 'trait_key'); + expect(updated.value, 'value'); + }); + }); + + group('toJson', () { + test( + 'When trait with int value converted to Map then success', + () { + final trait = Trait( + id: 12, + key: 'trait_key', + value: 42, + ); + + final mapped = trait.toJson(); + + expect(mapped, isNotNull); + expect(mapped['id'], 12); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], 42); + }); + + test( + 'When trait with string value converted to Map then success', + () { + final trait = Trait( + id: 12, + key: 'trait_key', + value: 'trait_value', + ); + + final mapped = trait.toJson(); + + expect(mapped, isNotNull); + expect(mapped['id'], 12); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], 'trait_value'); + }); + + test( + 'When trait with double value converted to Map then success', + () { + final trait = Trait( + id: 12, + key: 'trait_key', + value: 3.14, + ); + + final mapped = trait.toJson(); + + expect(mapped, isNotNull); + expect(mapped['id'], 12); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], 3.14); + }); + + test( + 'When trait with bool value converted to Map then success', + () { + final trait = Trait( + id: 12, + key: 'trait_key', + value: true, + ); + + final mapped = trait.toJson(); + + expect(mapped, isNotNull); + expect(mapped['id'], 12); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], true); + }); + + test('When transient trait converted to Map then success', + () { + final trait = Trait( + id: 12, + key: 'trait_key', + value: 'transient', + transient: true, + ); + + final mapped = trait.toJson(); + + expect(mapped, isNotNull); + expect(mapped['id'], 12); + expect(mapped['trait_key'], 'trait_key'); + expect(mapped['trait_value'], 'transient'); + expect(mapped['transient'], true); + }); + }); +} diff --git a/test/core/storage_reactive_test.dart b/test/core/storage_reactive_test.dart new file mode 100644 index 0000000..c8b83de --- /dev/null +++ b/test/core/storage_reactive_test.dart @@ -0,0 +1,53 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'package:test/test.dart'; + +import '../shared.dart'; + +void main() { + group('[Streams]', () { + StorageProvider store = StorageProvider(InMemoryStorage(), + password: 'pa5w0rD', logEnabled: true); + setUp(() async { + await store.seed(items: seeds); + }); + tearDown(() {}); + + test('Stream successfuly changed when flag was updated', () async { + expect( + store.stream(myFeatureName), + emitsInOrder([ + const TypeMatcher() + .having((s) => s.enabled, '$myFeatureName is enabled', true), + const TypeMatcher().having( + (s) => s.enabled, '$myFeatureName is not enabled', false), + ])); + final toggled = await store.togggleFeature(myFeatureName); + expect(toggled, isTrue); + }); + + test('Subject value changed when flag was changed.', () async { + await store.clear(); + await store.seed(items: seeds); + final feature = await store.read(myFeatureName); + expect(feature, isNotNull); + expect(feature!.enabled, isTrue); + expect(store.subject(myFeatureName)?.stream.valueOrNull?.enabled, true); + + expect( + store.subject(myFeatureName)?.stream, + emitsInOrder([ + const TypeMatcher() + .having((s) => s.enabled, '$myFeatureName is enabled', true) + .having((s) => s.feature.name, 'feature name is $myFeatureName', + myFeatureName), + const TypeMatcher() + .having( + (s) => s.enabled, '$myFeatureName is not enabled', false) + .having((s) => s.feature.name, 'feature name is $myFeatureName', + myFeatureName), + ])); + store.subject(myFeatureName)?.add(Flag.named( + feature: Feature.named(name: myFeatureName), enabled: false)); + }); + }); +} diff --git a/test/fg/flagsmith_analytics_test.dart b/test/fg/flagsmith_analytics_test.dart index 681494c..d42f2f2 100644 --- a/test/fg/flagsmith_analytics_test.dart +++ b/test/fg/flagsmith_analytics_test.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flagsmith/flagsmith.dart'; -import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:test/test.dart'; import '../shared.dart'; @@ -10,13 +9,12 @@ import '../shared.dart'; void main() { group('[Analytics] Settings', () { late FlagsmithClient fs; - late DioAdapter _adapter; setUp(() async { fs = setupSyncClientAdapter(StorageType.inMemory, caches: true, isDebug: true); setupAdapter(fs, cb: (config, adapter) { - _adapter = adapter; - _adapter.onPost(fs.config.analyticsURI, (server) { + adapter = adapter; + adapter.onPost(fs.config.analyticsURI, (server) { return server.reply(200, jsonDecode(analyticsData)); }, data: jsonDecode(analyticsData)); }); @@ -67,8 +65,8 @@ void main() { await fs.getFeatureFlagValue('my_feature'); expect(fs.flagAnalytics.containsKey('my_feature'), isTrue); expect(fs.flagAnalytics['my_feature'], 2); - final _response = await fs.syncAnalyticsData(); - expect(_response?.statusCode, 200); + final response = await fs.syncAnalyticsData(); + expect(response?.statusCode, 200); expect(fs.flagAnalytics.isEmpty, isTrue); }); test('When analytics was sent and failed, then current store is not empty', diff --git a/test/fg/flagsmith_caches_test.dart b/test/fg/flagsmith_caches_test.dart index 361a959..6e56067 100644 --- a/test/fg/flagsmith_caches_test.dart +++ b/test/fg/flagsmith_caches_test.dart @@ -14,12 +14,12 @@ void main() { fs.close(); }); test('When caches not enabled then fail', () async { - expect(() => fs.hasCachedFeatureFlag(notImplmentedFeature), + expect(() => fs.hasCachedFeatureFlag(notImplementedFeatureName), throwsA(isA())); }); test('When caches not enabled and get cached flag then fail', () async { - expect(() => fs.getCachedFeatureFlagValue(notImplmentedFeature), + expect(() => fs.getCachedFeatureFlagValue(notImplementedFeatureName), throwsA(isA())); }); }); @@ -35,31 +35,31 @@ void main() { }); test('When caches enabled then enabled', () async { await fs.getFeatureFlags(); - final _value = fs.hasCachedFeatureFlag(notImplmentedFeature); - expect(_value, false); + final value = fs.hasCachedFeatureFlag(notImplementedFeatureName); + expect(value, false); }); test('When caches enabled then get value', () async { await fs.getFeatureFlags(); - final _value = fs.hasCachedFeatureFlag(notImplmentedFeature); - expect(_value, false); + final value = fs.hasCachedFeatureFlag(notImplementedFeatureName); + expect(value, false); - final _flagValue = fs.getCachedFeatureFlagValue('min_version'); - expect(_flagValue, '2.0.0'); + final flagValue = fs.getCachedFeatureFlagValue('min_version'); + expect(flagValue, '2.0.0'); }); test('When feature flag remove then success', () async { - final _featureName = 'my_feature'; + final featureName = 'my_feature'; await fs.reset(); - final _current = await fs.hasFeatureFlag(_featureName); - expect(_current, true); + final current = await fs.hasFeatureFlag(featureName); + expect(current, true); - var result = await fs.removeFeatureFlag(_featureName); + var result = await fs.removeFeatureFlag(featureName); expect(result, true); - final _removed = await fs.hasFeatureFlag(_featureName); - expect(_removed, false); + final removed = await fs.hasFeatureFlag(featureName); + expect(removed, false); }); test('When cache is not empty', () async { diff --git a/test/fg/flagsmith_exceptions_test.dart b/test/fg/flagsmith_exceptions_test.dart index c637594..a3f5a0d 100644 --- a/test/fg/flagsmith_exceptions_test.dart +++ b/test/fg/flagsmith_exceptions_test.dart @@ -40,21 +40,21 @@ void main() { test( 'When exception with description raised, then we are able to read error description', () async { - final _description = 'generic error'; + final description = 'generic error'; expect( - () => Mocked().genericError(message: _description), + () => Mocked().genericError(message: description), throwsA(TypeMatcher().having( (s) => s.toString(), 'correct error description', - 'FlagsmithException: $_description'))); + 'FlagsmithException: $description'))); }); test( 'When exception without description raised, then we`ll see only type of exception', () async { - final String? _description = null; + final String? description = null; expect( - () => Mocked().genericError(message: _description), + () => Mocked().genericError(message: description), throwsA(TypeMatcher().having((s) => s.toString(), 'correct error description', 'FlagsmithException'))); }); @@ -87,8 +87,8 @@ void main() { throwsA(isA())); }); test('When create trait return error', () async { - var _user = Identity(identifier: 'test_another_user'); - final _data = TraitWithIdentity(identity: _user, key: 'age', value: '25'); + var user = Identity(identifier: 'test_another_user'); + final data = TraitWithIdentity(identity: user, key: 'age', value: '25'); fs = setupSyncClientAdapter(StorageType.inMemory); await fs.initialize(); setupAdapter(fs, cb: (config, adapter) { @@ -100,10 +100,10 @@ void main() { error: Exception('404'), ), ); - }, data: _data.toJson()); + }, data: data.toJson()); }); - expect(() => fs.createTrait(value: _data), + expect(() => fs.createTrait(value: data), throwsA(isA())); }); test('When bulk update traits, then fail', () async { @@ -118,23 +118,23 @@ void main() { error: Exception('404'), ), ); - }, data: jsonDecode(bulkTraitUpdateResponse)); + }, data: jsonDecode(identitiesRequestData)); }); - final _user = Identity(identifier: 'test_another_user'); - final _data = [ + final user = Identity(identifier: 'test_another_user'); + final data = [ TraitWithIdentity( - identity: _user, + identity: user, key: 'age', value: '21', ), TraitWithIdentity( - identity: _user, + identity: user, key: 'age2', value: '21', ) ]; - expect(() => fs.updateTraits(value: _data), + expect(() => fs.updateTraits(value: data), throwsA(isA())); }); @@ -146,32 +146,32 @@ void main() { return server.reply(200, null); }, data: []); }); - final _response = await fs.updateTraits(value: []); - expect(_response, isNull); + final response = await fs.updateTraits(value: []); + expect(response, isNull); }); test('When fetch flags, but data are malformed, then fail', () async { fs = setupSyncClientAdapter(StorageType.inMemory); - final _flag = - Flag.named(feature: Feature.named(name: myFeature), enabled: false); + final flag = Flag.named( + feature: Feature.named(name: myFeatureName), enabled: false); setupEmptyAdapter(fs, cb: (config, adapter) { - adapter.onGet(config.flagsURI, - (server) => server.reply(200, jsonEncode([_flag]))); + adapter.onGet( + config.flagsURI, (server) => server.reply(200, jsonEncode([flag]))); }); expect(() => fs.getFeatureFlags(), throwsA(isA())); }); test('When fetch user flags, but data are malformed, then fail', () async { - final _user = Identity(identifier: 'test_another_user'); - final _data = [ + final user = Identity(identifier: 'test_another_user'); + final data = [ TraitWithIdentity( - identity: _user, + identity: user, key: 'age', value: '21', ), TraitWithIdentity( - identity: _user, + identity: user, key: 'age2', value: '21', ) @@ -180,11 +180,11 @@ void main() { fs = setupSyncClientAdapter(StorageType.inMemory); setupEmptyAdapter(fs, cb: (config, adapter) { adapter.onGet(config.identitiesURI, - (server) => server.reply(200, jsonDecode(jsonEncode([_data]))), - queryParameters: _user.toJson()); + (server) => server.reply(200, jsonDecode(jsonEncode([data]))), + queryParameters: user.toJson()); }); - expect(() => fs.getFeatureFlags(user: _user), + expect(() => fs.getFeatureFlags(user: user), throwsA(isA())); }); }); diff --git a/test/fg/flagsmith_init_test.dart b/test/fg/flagsmith_init_test.dart index 333fda6..e88bca8 100644 --- a/test/fg/flagsmith_init_test.dart +++ b/test/fg/flagsmith_init_test.dart @@ -17,13 +17,13 @@ void main() { fs.close(); }); test('When caches not enabled then fail', () async { - expect(() => fs.hasCachedFeatureFlag(notImplmentedFeature), + expect(() => fs.hasCachedFeatureFlag(notImplementedFeatureName), throwsA(isA())); }); }); group('[Init] streams', () { - final _flag = - Flag.named(feature: Feature.named(name: myFeature), enabled: false); + final flag = + Flag.named(feature: Feature.named(name: myFeatureName), enabled: false); setUp(() async { fs = setupSyncClientAdapter( @@ -31,7 +31,7 @@ void main() { isDebug: true, ); setupEmptyAdapter(fs, cb: (config, adapter) { - adapter.onGet(config.flagsURI, (server) => server.reply(200, [_flag])); + adapter.onGet(config.flagsURI, (server) => server.reply(200, [flag])); }); await fs.initialize(); }); @@ -39,10 +39,10 @@ void main() { fs.close(); }); test('When change flag value, stream is updated', () async { - final _updated = _flag.copyWith(enabled: true); - expect(fs.stream(myFeature), emitsInOrder([_flag, _updated])); + final updated = flag.copyWith(enabled: true); + expect(fs.stream(myFeatureName), emitsInOrder([flag, updated])); await fs.getFeatureFlags(reload: true); - await fs.testToggle(myFeature); + await fs.testToggle(myFeatureName); }); }, skip: true); } diff --git a/test/fg/flagsmith_inmemory_test.dart b/test/fg/flagsmith_inmemory_test.dart index f4a202a..397c655 100644 --- a/test/fg/flagsmith_inmemory_test.dart +++ b/test/fg/flagsmith_inmemory_test.dart @@ -11,7 +11,7 @@ void main() { late FlagsmithClient fs; setUp(() async { fs = await setupClientAdapter(StorageType.inMemory, caches: true); - setupAdapter(fs, cb: (config, _adapter) {}); + setupAdapter(fs, cb: (config, adapter) {}); }); tearDown(() { fs.close(); @@ -47,10 +47,10 @@ void main() { expect(result, isNotNull); expect(result.length, seeds.length); - final value = fs.hasCachedFeatureFlag(notImplmentedFeature); + final value = fs.hasCachedFeatureFlag(notImplementedFeatureName); expect(value, false); - final value1 = await fs.hasFeatureFlag(notImplmentedFeature); + final value1 = await fs.hasFeatureFlag(notImplementedFeatureName); expect(value1, false); }); test('When get Features then success', () async { @@ -59,6 +59,11 @@ void main() { }); test('When get Features for user then success', () async { var user = Identity(identifier: 'test_sample_user'); + setupEmptyAdapter(fs, cb: (config, adapter) { + adapter.onPost(fs.config.identitiesURI, (server) { + return server.reply(200, jsonDecode(identitiesResponseData)); + }, data: user.toJson()); + }); var result = await fs.getFeatureFlags(user: user); expect(result, isNotNull); expect(result, isNotEmpty); @@ -74,7 +79,7 @@ void main() { expect(resultNext, isNotEmpty); }); test('When flag is not presented then false', () async { - var result = await fs.isFeatureFlagEnabled(notImplmentedFeature); + var result = await fs.isFeatureFlagEnabled(notImplementedFeatureName); expect(result, false); }); test('When flag is presented then true', () async { @@ -83,7 +88,7 @@ void main() { }); test('When flag is not presented then value is null', () async { - var result = await fs.getFeatureFlagValue(notImplmentedFeature); + var result = await fs.getFeatureFlagValue(notImplementedFeatureName); expect(result, isNull); }); @@ -94,23 +99,23 @@ void main() { }); test('When feature flag remove then success', () async { - final _featureName = 'my_feature'; + final featureName = 'my_feature'; await fs.reset(); - final _current = await fs.hasFeatureFlag(_featureName); - expect(_current, true); + final current = await fs.hasFeatureFlag(featureName); + expect(current, true); - var result = await fs.removeFeatureFlag(_featureName); + var result = await fs.removeFeatureFlag(featureName); expect(result, true); - final _removed = await fs.hasFeatureFlag(_featureName); - expect(_removed, false); + final removed = await fs.hasFeatureFlag(featureName); + expect(removed, false); }); }); group('[InMemory storage] flags api failures', () { late FlagsmithClient fs; - final _identity = Identity(identifier: 'invalid_users_another_user'); + final identity = Identity(identifier: 'invalid_users_another_user'); setUp(() async { fs = await setupClientAdapter(StorageType.inMemory, caches: true); }); @@ -118,8 +123,8 @@ void main() { fs.close(); }); test('When get flags then 404 error', () async { - setupEmptyAdapter(fs, cb: (config, _adapter) { - _adapter + setupEmptyAdapter(fs, cb: (config, adapter) { + adapter ..onGet( config.flagsURI, (server) => server.throws( @@ -136,35 +141,35 @@ void main() { requestOptions: RequestOptions(path: config.flagsURI), ), ); - }, queryParameters: _identity.toJson()); + }, queryParameters: identity.toJson()); }); expect(() => fs.getFeatureFlags(), throwsA(isA())); expect(fs.cachedFlags, isNotEmpty); - expect(() => fs.getFeatureFlags(user: _identity), + expect(() => fs.getFeatureFlags(user: identity), throwsA(isA())); }); test( 'When get flags returns statusCode > 200 && < 300, then success but empty', () async { - setupEmptyAdapter(fs, cb: (config, _adapter) { - _adapter + setupEmptyAdapter(fs, cb: (config, adapter) { + adapter ..onGet(config.flagsURI, (server) => server.reply(201, jsonDecode('''[]'''))) - ..onGet(fs.config.identitiesURI, (server) { + ..onPost(fs.config.identitiesURI, (server) { return server.reply(201, null); - }, queryParameters: _identity.toJson()); + }, data: identity.toJson()); }); expect(await fs.getFeatureFlags(), isEmpty); - expect(await fs.getFeatureFlags(user: _identity), isEmpty); + expect(await fs.getFeatureFlags(user: identity), isEmpty); }); test('When get user flags returns null, then success but empty', () async { - setupEmptyAdapter(fs, cb: (config, _adapter) { - _adapter.onGet(fs.config.identitiesURI, (server) { + setupEmptyAdapter(fs, cb: (config, adapter) { + adapter.onPost(fs.config.identitiesURI, (server) { return server.reply(200, null); - }, queryParameters: _identity.toJson()); + }, data: identity.toJson()); }); - expect(await fs.getFeatureFlags(user: _identity), isEmpty); + expect(await fs.getFeatureFlags(user: identity), isEmpty); }); }); } diff --git a/test/fg/flagsmith_realtime_test.dart b/test/fg/flagsmith_realtime_test.dart index 170c46c..2284879 100644 --- a/test/fg/flagsmith_realtime_test.dart +++ b/test/fg/flagsmith_realtime_test.dart @@ -1,6 +1,5 @@ import 'package:flagsmith/flagsmith.dart'; import 'package:flutter_client_sse/flutter_client_sse.dart'; -import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:test/test.dart'; import '../shared.dart'; @@ -10,13 +9,12 @@ const userIdentifier = 'test__user'; void main() { group('[Realtime updates]', () { late FlagsmithClient fs; - late DioAdapter _adapter; setUp(() async { fs = setupSyncClientAdapter(StorageType.inMemory); setupAdapter(fs, cb: (config, adapter) { - _adapter = adapter; - _adapter.onGet(fs.config.identitiesURI, (server) { + adapter = adapter; + adapter.onGet(fs.config.identitiesURI, (server) { return server.reply(200, null); }, queryParameters: { 'identifier': userIdentifier, diff --git a/test/fg/flagsmith_streams_test.dart b/test/fg/flagsmith_streams_test.dart index 63b2622..984f1a5 100644 --- a/test/fg/flagsmith_streams_test.dart +++ b/test/fg/flagsmith_streams_test.dart @@ -8,7 +8,7 @@ void main() { late FlagsmithClient fs; setUp(() async { fs = await setupClientAdapter(StorageType.inMemory, caches: true); - setupAdapter(fs, cb: (config, _adapter) {}); + setupAdapter(fs, cb: (config, adapter) {}); }); tearDown(() { fs.close(); @@ -26,37 +26,38 @@ void main() { test('Stream successfuly changed when flag was updated', () async { await fs.reset(); - expect(await fs.isFeatureFlagEnabled(myFeature), true); + expect(await fs.isFeatureFlagEnabled(myFeatureName), true); expect( - fs.stream(myFeature), + fs.stream(myFeatureName), emitsInOrder([ TypeMatcher() - .having((s) => s.enabled, '$myFeature is enabled', true), - TypeMatcher() - .having((s) => s.enabled, '$myFeature is not enabled', false), + .having((s) => s.enabled, '$myFeatureName is enabled', true), + TypeMatcher().having( + (s) => s.enabled, '$myFeatureName is not enabled', false), ])); - await fs.testToggle(myFeature); + await fs.testToggle(myFeatureName); }); test('Subject value changed when flag was changed.', () async { await fs.reset(); - expect(await fs.isFeatureFlagEnabled(myFeature), true); - expect(fs.subject(myFeature)?.stream.valueOrNull?.enabled, true); + expect(await fs.isFeatureFlagEnabled(myFeatureName), true); + expect(fs.subject(myFeatureName)?.stream.valueOrNull?.enabled, true); expect( - fs.subject(myFeature)?.stream, + fs.subject(myFeatureName)?.stream, emitsInOrder([ TypeMatcher() - .having((s) => s.enabled, '$myFeature is enabled', true) - .having((s) => s.feature.name, 'feature name is $myFeature', - myFeature), + .having((s) => s.enabled, '$myFeatureName is enabled', true) + .having((s) => s.feature.name, 'feature name is $myFeatureName', + myFeatureName), TypeMatcher() - .having((s) => s.enabled, '$myFeature is not enabled', false) - .having((s) => s.feature.name, 'feature name is $myFeature', - myFeature), + .having( + (s) => s.enabled, '$myFeatureName is not enabled', false) + .having((s) => s.feature.name, 'feature name is $myFeatureName', + myFeatureName), ])); - fs.subject(myFeature)?.add( - Flag.named(feature: Feature.named(name: myFeature), enabled: false)); + fs.subject(myFeatureName)?.add(Flag.named( + feature: Feature.named(name: myFeatureName), enabled: false)); }); }); } diff --git a/test/fg/flagsmith_traits_test.dart b/test/fg/flagsmith_traits_test.dart index c63b23a..09c5b03 100644 --- a/test/fg/flagsmith_traits_test.dart +++ b/test/fg/flagsmith_traits_test.dart @@ -2,21 +2,19 @@ import 'dart:convert'; import 'package:flagsmith/flagsmith.dart'; import 'package:test/test.dart'; -import 'package:http_mock_adapter/http_mock_adapter.dart'; import '../shared.dart'; void main() { group('[Flag manipulation]', () { late FlagsmithClient fs; - late DioAdapter _adapter; setUp(() async { fs = setupSyncClientAdapter(StorageType.inMemory); setupAdapter(fs, cb: (config, adapter) { - _adapter = adapter; - _adapter + adapter = adapter; + adapter ..onGet(fs.config.identitiesURI, (server) { - return server.reply(200, jsonDecode(fakeIdentitiesResponse)); + return server.reply(200, jsonDecode(identitiesResponseData)); }, queryParameters: { 'identifier': 'test_another_user' }) @@ -26,8 +24,8 @@ void main() { 'identifier': 'invalid_users_another_user' }) ..onPost(fs.config.identitiesURI, (server) { - return server.reply(200, jsonDecode(bulkTraitUpdateResponse)); - }, data: jsonDecode(bulkTraitUpdateResponse)) + return server.reply(200, jsonDecode(identitiesResponseData)); + }, data: jsonDecode(identitiesResponseData)) ..onPost(fs.config.traitsURI, (server) { return server.reply(200, jsonDecode(traitAge25)); }, data: jsonDecode(traitAge25)); @@ -50,18 +48,18 @@ void main() { }); test('When localy change state of flag, then success', () async { await fs.getFeatureFlags(); - expect(await fs.isFeatureFlagEnabled(myFeature), true); - await fs.testToggle(myFeature); - expect(await fs.isFeatureFlagEnabled(myFeature), false); + expect(await fs.isFeatureFlagEnabled(myFeatureName), true); + await fs.testToggle(myFeatureName); + expect(await fs.isFeatureFlagEnabled(myFeatureName), false); }); test('When change state of flag, then cache success', () async { fs = setupSyncClientAdapter(StorageType.inMemory, caches: true); setupAdapter(fs, cb: (config, adapter) {}); await fs.getFeatureFlags(); - expect(await fs.isFeatureFlagEnabled(myFeature), true); - await fs.testToggle(myFeature); - expect(await fs.isFeatureFlagEnabled(myFeature), false); + expect(await fs.isFeatureFlagEnabled(myFeatureName), true); + await fs.testToggle(myFeatureName); + expect(await fs.isFeatureFlagEnabled(myFeatureName), false); }); }); @@ -72,7 +70,7 @@ void main() { setupAdapter(fs, cb: (config, adapter) { adapter ..onGet(fs.config.identitiesURI, (server) { - return server.reply(200, jsonDecode(fakeIdentitiesResponse)); + return server.reply(200, jsonDecode(identitiesResponseData)); }, queryParameters: { 'identifier': 'test_another_user' }) @@ -82,8 +80,8 @@ void main() { 'identifier': 'invalid_users_another_user' }) ..onPost(fs.config.identitiesURI, (server) { - return server.reply(200, jsonDecode(bulkTraitUpdateResponse)); - }, data: jsonDecode(bulkTraitUpdateResponse)) + return server.reply(200, jsonDecode(identitiesResponseData)); + }, data: jsonDecode(identitiesRequestData)) ..onPost(fs.config.traitsURI, (server) { return server.reply(200, jsonDecode(traitAge25)); }, data: jsonDecode(traitAge25)); @@ -193,7 +191,7 @@ void main() { setupAdapter(fs, cb: (config, adapter) { adapter ..onGet(fs.config.identitiesURI, (server) { - return server.reply(200, jsonDecode(fakeIdentitiesResponse)); + return server.reply(200, jsonDecode(identitiesResponseData)); }, queryParameters: { 'identifier': 'test_another_user' }) @@ -203,8 +201,8 @@ void main() { 'identifier': 'invalid_users_another_user' }) ..onPost(fs.config.identitiesURI, (server) { - return server.reply(200, jsonDecode(bulkTraitUpdateResponse)); - }, data: jsonDecode(bulkTraitUpdateResponse)) + return server.reply(200, jsonDecode(identitiesResponseData)); + }, data: jsonDecode(identitiesResponseData)) // request ..onPost(fs.config.traitsURI, (server) { return server.reply(200, jsonDecode(traitAge25)); }, data: jsonDecode(traitAge25)); @@ -241,17 +239,17 @@ void main() { }); test('When create trait return data null', () async { - var _user = Identity(identifier: 'test_another_user'); - final _data = TraitWithIdentity(identity: _user, key: 'age', value: '25'); + var user = Identity(identifier: 'test_another_user'); + final data = TraitWithIdentity(identity: user, key: 'age', value: '25'); fs = setupSyncClientAdapter(StorageType.inMemory); await fs.initialize(); setupAdapter(fs, cb: (config, adapter) { adapter.onPost(fs.config.traitsURI, (server) { return server.reply(200, null); - }, data: _data.toJson()); + }, data: data.toJson()); }); - final _result = await fs.createTrait(value: _data); - expect(_result, isNull); + final result0 = await fs.createTrait(value: data); + expect(result0, isNull); }); }); } diff --git a/test/shared.dart b/test/shared.dart index 06b3750..ad31997 100644 --- a/test/shared.dart +++ b/test/shared.dart @@ -31,9 +31,10 @@ final seeds = [ Flag.seed('enabled_feature', enabled: true), Flag.seed('enabled_value', enabled: true, value: '2.0.0') ]; -const notImplmentedFeature = 'not_implemented_flag'; -const myFeature = 'my_feature'; -final fakeMallformedResponse = r'''[ +final notImplementedFeatureName = 'not_implemented_flag'; +final myFeatureName = 'my_feature'; + +final malformedResponseData = r'''[ { "id": 48540, "feature": { @@ -52,7 +53,8 @@ final fakeMallformedResponse = r'''[ "feature_segment": null, }, ]'''; -final fakeResponse = r'''[ + +final flagsResponseData = r'''[ { "id": 48540, "feature": { @@ -531,7 +533,8 @@ final fakeResponse = r'''[ "feature_segment": null } ]'''; -final fakeIdentitiesResponse = r'''{ + +final identitiesResponseData = r'''{ "flags": [ { "id": 48540, @@ -682,7 +685,24 @@ final fakeIdentitiesResponse = r'''{ ] }'''; -final bulkTraitUpdateResponse = '''{ +final bulkTraitUpdateResponseData = '''[ + { + "identity": { + "identifier": "test_another_user" + }, + "trait_key": "age", + "trait_value": "21" + }, + { + "identity": { + "identifier": "test_another_user" + }, + "trait_key": "age2", + "trait_value": "21" + } +]'''; + +final identitiesRequestData = '''{ "identifier": "test_another_user", "traits": [ { @@ -704,7 +724,7 @@ final bulkTraitUpdateResponse = '''{ ] }'''; -final createTraitRequest = '''{ +final createTraitRequestData = '''{ "identifier": "test_another_user" "flags":[ { @@ -777,10 +797,10 @@ void setupAdapter(FlagsmithClient fs, final config = fs.config; final dioAdapter = DioAdapter(dio: fs.client); - dioAdapter.onGet( - config.flagsURI, (server) => server.reply(200, jsonDecode(fakeResponse))); - dioAdapter.onGet(config.identitiesURI, - (server) => server.reply(200, jsonDecode(fakeIdentitiesResponse))); + dioAdapter.onGet(config.flagsURI, + (server) => server.reply(200, jsonDecode(flagsResponseData))); + dioAdapter.onPost(config.identitiesURI, + (server) => server.reply(200, jsonDecode(identitiesResponseData))); if (cb != null) { cb(config, dioAdapter); diff --git a/test/storage/storage_test.dart b/test/storage/storage_test.dart new file mode 100644 index 0000000..65d0789 --- /dev/null +++ b/test/storage/storage_test.dart @@ -0,0 +1,100 @@ +import 'package:flagsmith/flagsmith.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../shared.dart'; + +void main() { + SharedPreferences.setMockInitialValues({}); + + final CoreStorage storage = FlagsmithSharedPreferenceStore(); + late StorageProvider storageProvider; + + setUpAll(() { + storageProvider = + StorageProvider(storage, password: 'pa5w0rD', logEnabled: true); + }); + + test('adds one to input values', () async { + final response = await storageProvider.seed(items: seeds); + expect(response, true); + final all = await storageProvider.getAll(); + expect(all.length, seeds.length); + }); + test('When update value', () async { + final response = await storageProvider.read(myFeatureName); + expect(response, isNotNull); + expect(response!.enabled, isTrue); + }); + test('Update with enabled false', () async { + await storageProvider.update( + myFeatureName, Flag.seed(myFeatureName, enabled: false)); + final responseUpdated = await storageProvider.read(myFeatureName); + expect(responseUpdated, isNotNull); + expect(responseUpdated!.enabled, isFalse); + }); + test('Remove item from storage', () async { + await storageProvider.delete(myFeatureName); + final items = await storageProvider.getAll(); + expect(items, isNotEmpty); + expect(items.length, seeds.length - 1); + }); + test('Remove item from storage', () async { + await storageProvider.clear(); + final items = await storageProvider.getAll(); + expect(items, isEmpty); + }); + test('Create a flag', () async { + final created = await storageProvider.create( + 'test_feature', Flag.seed('test_feature', enabled: false)); + expect(created, isTrue); + }); + test('Save all flags', () async { + final created = await storageProvider.saveAll([ + Flag.seed('test_feature_a', enabled: false), + Flag.seed('test_feature_b', enabled: true) + ]); + expect(created, isTrue); + final all0 = await storageProvider.getAll(); + expect(all0, isNotEmpty); + expect( + all0, + const TypeMatcher>().having( + (p0) => p0 + .firstWhereOrNull((element) => element.key == 'test_feature_a'), + 'saved flags containse feature flag `test_feature_a`', + isNotNull)); + }); + + test('Update all flags', () async { + final created = await storageProvider.saveAll([ + Flag.seed('test_feature_a', enabled: false), + Flag.seed('test_feature_b', enabled: true) + ]); + expect(created, isTrue); + final all0 = await storageProvider.getAll(); + expect(all0, isNotEmpty); + expect( + all0, + const TypeMatcher>().having( + (p0) => p0 + .firstWhereOrNull((element) => element.key == 'test_feature_a'), + 'saved flags containse feature flag `test_feature_a`', + isNotNull)); + }); + + test('Init storage over', () async { + storageProvider = + StorageProvider(storage, password: 'pa5w0rD', logEnabled: true); + await storageProvider.clear(); + expect(await storageProvider.seed(items: seeds), isTrue); + expect(await storageProvider.seed(items: seeds), isFalse); + }); + + test('Init storage missing passowrd', () { + expect(() { + StorageProvider(storage, password: null, logEnabled: true); + }, throwsA(isA())); + }); +}