diff --git a/apps/playground/integration_test/survey_filtering_qa_test.dart b/apps/playground/integration_test/survey_filtering_qa_test.dart new file mode 100644 index 0000000..41f4a49 --- /dev/null +++ b/apps/playground/integration_test/survey_filtering_qa_test.dart @@ -0,0 +1,355 @@ +/// E2E QA for ENG-1128 survey eligibility filtering, against a real local +/// Formbricks backend and the real survey runtime in a WebView. +/// +/// Run (simulator booted, backend on localhost:3000): +/// ```sh +/// fvm flutter test integration_test/survey_filtering_qa_test.dart \ +/// -d UDID \ +/// --dart-define=APP_URL=http://localhost:3000 \ +/// --dart-define=WORKSPACE_ID=WSP_ID \ +/// --dart-define=SURVEY_ID=SURVEY_ID \ +/// --dart-define=SCENARIO=baseline +/// ``` +/// +/// SCENARIO selects the assertion set; the survey must be configured in the +/// dashboard to match (see the QA matrix in the PR/session notes). +library; + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/formbricks_flutter.dart'; +import 'package:formbricks_playground/main.dart'; +import 'package:integration_test/integration_test.dart'; + +const String _surveyId = String.fromEnvironment('SURVEY_ID'); +const String _scenario = String.fromEnvironment( + 'SCENARIO', + defaultValue: 'baseline', +); + +Future> _storedConfig() async { + final raw = await Formbricks.debugStoredConfig(); + expect(raw, isNotNull, reason: 'no persisted config'); + return jsonDecode(raw!) as Map; +} + +List _filteredIds(Map config) => + ((config['filteredSurveys'] as List?) ?? const []) + .map((e) => (e as Map)['id'] as String) + .toList(); + +List _displays(Map config) => + ((config['user'] as Map)['data'] as Map)['displays'] as List? ?? const []; + +/// Polls the persisted config until [predicate] passes or [timeout] elapses. +Future> _waitForConfig( + WidgetTester tester, + bool Function(Map) predicate, { + Duration timeout = const Duration(seconds: 20), + String? what, +}) async { + final deadline = DateTime.now().add(timeout); + late Map config; + while (true) { + config = await _storedConfig(); + if (predicate(config)) return config; + if (DateTime.now().isAfter(deadline)) { + fail( + 'timed out waiting for ${what ?? 'config condition'}; ' + 'filteredSurveys=${_filteredIds(config)} ' + 'displays=${_displays(config).length}', + ); + } + await tester.pump(const Duration(milliseconds: 300)); + await Future.delayed(const Duration(milliseconds: 300)); + } +} + +Future _pumpApp(WidgetTester tester) async { + await tester.pumpWidget(const PlaygroundApp()); + // Wait for auto-connect (dart-defines) to complete. + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (find.textContaining('Connected to').evaluate().isEmpty) { + if (DateTime.now().isAfter(deadline)) { + fail('app never reached connected state'); + } + await tester.pump(const Duration(milliseconds: 300)); + await Future.delayed(const Duration(milliseconds: 300)); + } +} + +Future _tapButton(WidgetTester tester, String label) async { + final finder = find.text(label); + await tester.ensureVisible(finder); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(finder); + await tester.pump(); +} + +Future _trigger(WidgetTester tester) => + _tapButton(tester, 'Trigger Code Action'); + +/// Closes an open survey by popping its modal route. +Future _closeSurvey(WidgetTester tester) async { + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); + await tester.pump(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); +} + +/// Waits until the display count for the session reaches [count] — proof the +/// real survey runtime rendered and fired onDisplayCreated over the bridge. +Future> _waitForDisplays(WidgetTester tester, int count) => + _waitForConfig( + tester, + (c) => _displays(c).length >= count, + what: 'display #$count', + ); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('scenario: $_scenario', (tester) async { + expect(_surveyId, isNotEmpty, reason: 'pass --dart-define=SURVEY_ID=...'); + + await Formbricks.debugClearStoredConfig(); + await _pumpApp(tester); + + switch (_scenario) { + // Survey: displaySome (limit >= 2), no recontact, no percentage, + // segment without filters. Expect: eligible, shows repeatedly, + // displays recorded. + case 'baseline': + var config = await _waitForConfig( + tester, + (c) => _filteredIds(c).contains(_surveyId), + what: 'survey eligible after fresh sync', + ); + expect(_displays(config), isEmpty); + + await _trigger(tester); + config = await _waitForDisplays(tester, 1); + final userData = (config['user'] as Map)['data'] as Map; + debugPrint( + 'QA filtered=${_filteredIds(config)} ' + 'responses=${userData['responses']} ' + 'lastDisplayAt=${userData['lastDisplayAt']} ' + 'displays=${jsonEncode(userData['displays'])}', + ); + debugPrint( + 'QA survey=${jsonEncode(((config['workspace'] as Map)['data'] as Map)['surveys'])}', + ); + expect( + _filteredIds(config), + contains(_surveyId), + reason: 'displaySome below limit stays eligible', + ); + await _closeSurvey(tester); + + await _trigger(tester); + await _waitForDisplays(tester, 2); + await _closeSurvey(tester); + + // Survey: displayOnce. Expect: shows once, then drops out of the + // eligible set immediately; second trigger shows nothing. + case 'displayOnce': + await _waitForConfig( + tester, + (c) => _filteredIds(c).contains(_surveyId), + what: 'survey eligible after fresh sync', + ); + + await _trigger(tester); + final config = await _waitForDisplays(tester, 1); + expect( + _filteredIds(config), + isNot(contains(_surveyId)), + reason: 'displayOnce must drop out after its display', + ); + await _closeSurvey(tester); + + await _trigger(tester); + await tester.pump(const Duration(seconds: 3)); + await Future.delayed(const Duration(seconds: 3)); + expect( + _displays(await _storedConfig()).length, + 1, + reason: 'second trigger must not display again', + ); + + // Survey: displaySome with displayLimit=2. Expect: two displays, then + // ineligible. + case 'displayLimit2': + await _waitForConfig( + tester, + (c) => _filteredIds(c).contains(_surveyId), + what: 'survey eligible after fresh sync', + ); + + await _trigger(tester); + var config = await _waitForDisplays(tester, 1); + expect(_filteredIds(config), contains(_surveyId)); + await _closeSurvey(tester); + + await _trigger(tester); + config = await _waitForDisplays(tester, 2); + expect( + _filteredIds(config), + isNot(contains(_surveyId)), + reason: 'at displayLimit the survey must drop out', + ); + await _closeSurvey(tester); + + await _trigger(tester); + await tester.pump(const Duration(seconds: 3)); + await Future.delayed(const Duration(seconds: 3)); + expect(_displays(await _storedConfig()).length, 2); + + // Survey: recontactDays >= 1 (survey-level). Expect: shows once; + // after close the recontact window excludes it. + case 'recontactDays': + await _waitForConfig( + tester, + (c) => _filteredIds(c).contains(_surveyId), + what: 'survey eligible after fresh sync', + ); + + await _trigger(tester); + await _waitForDisplays(tester, 1); + await _closeSurvey(tester); + + final config = await _waitForConfig( + tester, + (c) => !_filteredIds(c).contains(_surveyId), + what: 'recontact window to exclude the survey', + ); + expect( + (((config['user'] as Map)['data'] as Map)['lastDisplayAt']), + isNotNull, + ); + + // Survey: displayPercentage set to a mid value (e.g. 50). Expect: + // stays eligible, but across many triggers some are skipped and some + // shown. Statistical, so only sanity-checked. + case 'displayPercentage': + await _waitForConfig( + tester, + (c) => _filteredIds(c).contains(_surveyId), + what: 'survey eligible after fresh sync', + ); + + var shown = 0; + for (var i = 0; i < 12; i++) { + final before = _displays(await _storedConfig()).length; + await _trigger(tester); + await tester.pump(const Duration(seconds: 2)); + await Future.delayed(const Duration(seconds: 2)); + final after = _displays(await _storedConfig()).length; + if (after > before) { + shown++; + await _closeSurvey(tester); + } + } + // 12 rolls at 50%: 0 or 12 each have p ≈ 0.02% — treat as failure. + expect(shown, greaterThan(0), reason: 'never shown across 12 rolls'); + expect(shown, lessThan(12), reason: 'never skipped across 12 rolls'); + + // Survey: segment WITH filters. Expect: anonymous session never sees + // it; it is not in filteredSurveys and a trigger shows nothing. + case 'segmentAnonymous': + final config = await _waitForConfig( + tester, + (c) => (c['workspace'] != null), + what: 'workspace synced', + ); + expect(_filteredIds(config), isNot(contains(_surveyId))); + + await _trigger(tester); + await tester.pump(const Duration(seconds: 3)); + await Future.delayed(const Duration(seconds: 3)); + expect(_displays(await _storedConfig()), isEmpty); + + // Survey: segment without filters, respondMultiple. Identifies the + // user, asserts the segment branch the backend takes (matched → + // eligible; unmatched → nothing), then logs out and asserts the + // anonymous refilter restores eligibility. + case 'segmentIdentified': + // Anonymous baseline: with a filtered segment the survey must be + // hidden; without filters it must be eligible. + await _waitForConfig( + tester, + (c) => c['workspace'] != null, + what: 'workspace synced', + ); + + await _tapButton(tester, 'Set userId'); + var config = await _waitForConfig( + tester, + (c) => + ((c['user'] as Map)['data'] as Map)['userId'] == + 'playground-user', + what: 'identity flush', + ); + final segments = + (((config['user'] as Map)['data'] as Map)['segments'] as List) + .cast(); + debugPrint( + 'QA identified segments=$segments ' + 'filtered=${_filteredIds(config)}', + ); + if (segments.isEmpty) { + expect( + _filteredIds(config), + isEmpty, + reason: 'identified with no matched segments sees nothing', + ); + await _trigger(tester); + await tester.pump(const Duration(seconds: 3)); + await Future.delayed(const Duration(seconds: 3)); + expect(_displays(await _storedConfig()), isEmpty); + } else { + expect( + _filteredIds(config), + contains(_surveyId), + reason: 'identified user matched the survey segment', + ); + await _trigger(tester); + await _waitForDisplays(tester, 1); + await _closeSurvey(tester); + } + + await _tapButton(tester, 'Logout'); + config = await _waitForConfig( + tester, + (c) => ((c['user'] as Map)['data'] as Map)['userId'] == null, + what: 'logout teardown', + ); + // After logout the anonymous refilter applies: a segment WITH + // filters hides the survey, one without filters restores it. + final rawSurvey = + (((config['workspace'] as Map)['data'] as Map)['surveys'] as List) + .cast>() + .firstWhere((s) => s['id'] == _surveyId); + final segment = rawSurvey['segment'] as Map?; + final segmentHasFilters = + segment?['hasFilters'] == true || + (segment?['filters'] as List?)?.isNotEmpty == true; + debugPrint( + 'QA post-logout segmentHasFilters=$segmentHasFilters ' + 'filtered=${_filteredIds(config)}', + ); + expect( + _filteredIds(config), + segmentHasFilters ? isNot(contains(_surveyId)) : contains(_surveyId), + reason: segmentHasFilters + ? 'segment-filtered survey must drop after logout' + : 'anonymous refilter restores the unfiltered survey', + ); + + default: + fail('unknown SCENARIO "$_scenario"'); + } + }); +} diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 3d0a1fc..8718390 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -333,7 +333,6 @@ class _PlaygroundHomeState extends State { ), ], const Divider(height: 40), - for (final a in identityActions) ...[ FilledButton.tonal( onPressed: _connected diff --git a/apps/playground/pubspec.yaml b/apps/playground/pubspec.yaml index 9c4b3bb..068af90 100644 --- a/apps/playground/pubspec.yaml +++ b/apps/playground/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/packages/formbricks_flutter/lib/src/common/api_client.dart b/packages/formbricks_flutter/lib/src/common/api_client.dart index e28f4f6..9126b4e 100644 --- a/packages/formbricks_flutter/lib/src/common/api_client.dart +++ b/packages/formbricks_flutter/lib/src/common/api_client.dart @@ -9,9 +9,8 @@ import 'result.dart'; /// Thin wrapper over `package:http` for the two client endpoints the SDK needs. /// -/// Ported from the RN `ApiClient` / `makeRequest` (`lib/common/api.ts`). The -/// `http.Client` is injectable so tests can swap in a mock; no `dio`, to keep -/// the dependency graph minimal. +/// The `http.Client` is injectable so tests can swap in a mock; no `dio`, to +/// keep the dependency graph minimal. class ApiClient { /// Creates an API client for [appUrl] / [workspaceId]. ApiClient({ @@ -128,10 +127,9 @@ class ApiClient { } /// Fetches workspace state via `GET /api/v2/client/{workspaceId}/environment`. - /// /// Normalizes the legacy field name: the server may return the settings object /// under `data.settings` (new), `data.workspace`, or legacy `data.project`. - /// All three are mapped to `data.settings` (port of `workspace/state.ts`). + /// All three are mapped to `data.settings`. Future> getWorkspaceState() { return _request( method: 'GET', diff --git a/packages/formbricks_flutter/lib/src/common/command_queue.dart b/packages/formbricks_flutter/lib/src/common/command_queue.dart index 85249db..bb33c27 100644 --- a/packages/formbricks_flutter/lib/src/common/command_queue.dart +++ b/packages/formbricks_flutter/lib/src/common/command_queue.dart @@ -10,9 +10,8 @@ typedef _Runner = Future Function(); /// /// Every public SDK method routes through this so calls execute in strict /// submission order — `setUserId` followed by `setAttribute` can't race, because -/// the attribute call needs the userId already applied. Ported from the RN -/// `CommandQueue`, with one difference: each [add] returns its own `Future` -/// instead of a shared `wait()`. +/// the attribute call needs the userId already applied. Each [add] returns its +/// own `Future`. class CommandQueue { /// Creates a queue. [isSetup] reports whether `setup()` has completed; it is /// injected so the queue stays decoupled from the setup module (and testable). diff --git a/packages/formbricks_flutter/lib/src/common/config.dart b/packages/formbricks_flutter/lib/src/common/config.dart index 354337a..bd6c6d9 100644 --- a/packages/formbricks_flutter/lib/src/common/config.dart +++ b/packages/formbricks_flutter/lib/src/common/config.dart @@ -9,7 +9,7 @@ import 'time.dart'; /// Persistent SDK config, backed by [SharedPreferences] under [storageKey]. /// -/// Design choices (improvements over the RN `RNConfig`): +/// Design choices: /// - **Init once.** [init] reads storage a single time during `setup()`; after /// that [get] is synchronous and does not re-read the disk. /// - **Awaited persistence.** [update] completes only after the disk write, so a @@ -24,8 +24,8 @@ class FormbricksConfig { /// The process-wide config singleton. static FormbricksConfig get instance => _instance ??= FormbricksConfig._(); - /// SharedPreferences key. Distinct from RN's `formbricks-react-native` so the - /// two SDKs never collide on one device. + /// SharedPreferences key, namespaced to this SDK so multiple Formbricks SDKs + /// on one device never collide on storage. static const String storageKey = 'formbricks-flutter'; TConfig? _config; @@ -33,8 +33,8 @@ class FormbricksConfig { /// Loads and parses the cached config exactly once. /// - /// A cached config whose **workspace** has expired is discarded (matches RN's - /// `loadFromStorage`). An error-only config (no workspace) is kept so the + /// A cached config whose **workspace** has expired is discarded. An error-only + /// config (no workspace) is kept so the /// error cooldown survives a reload. Future init() async { final prefs = _prefs ??= await SharedPreferences.getInstance(); diff --git a/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart b/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart index dbb9f64..65473af 100644 --- a/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart +++ b/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart @@ -6,17 +6,17 @@ import 'package:flutter/widgets.dart'; import '../types/config.dart'; import 'api_client.dart'; import 'config.dart'; +import 'filter_surveys.dart'; import 'logger.dart'; import 'result.dart'; import 'time.dart'; /// A single, lifecycle-aware expiry ticker. /// -/// Replaces the RN SDK's two separate 60s tickers (workspace + user) with one -/// `Timer.periodic` that checks both. It is also lifecycle-aware: it cancels the -/// timer while the app is backgrounded and runs an immediate check the moment -/// the app resumes — RN ran its tickers regardless of app state, wasting battery -/// and racing storage on resume. +/// A single `Timer.periodic` checks both the workspace and user expiries. It is +/// lifecycle-aware: it cancels the timer while the app is backgrounded and runs +/// an immediate check the moment the app resumes, so it doesn't waste battery +/// or race storage on resume. class ExpiryTicker with WidgetsBindingObserver { /// Creates a ticker bound to [config] and [apiClient]. /// @@ -74,8 +74,7 @@ class ExpiryTicker with WidgetsBindingObserver { void _scheduleTimer() { // Always cancel any existing timer first, so a resume can never leave two - // periodic timers running (the RN dual-ticker bug this design guards - // against). + // periodic timers running. _timer?.cancel(); _timer = Timer.periodic(interval, (_) => unawaited(_tick())); } @@ -111,17 +110,27 @@ class ExpiryTicker with WidgetsBindingObserver { if (workspace != null && isNowExpired(workspace.expiresAt)) { Logger.debug('Workspace state has expired. Starting sync.'); final result = await apiClient.getWorkspaceState(); + // Re-read after the round-trip so concurrent writes aren't clobbered. + final synced = config.getOrNull(); + if (synced == null) return; switch (result) { case Ok(:final value): - await config.update(snapshot.copyWith(workspace: value)); + await config.update( + synced.copyWith( + workspace: value, + filteredSurveys: filterSurveys(value, synced.user) + .map((s) => s.toJson()) + .toList(), + ), + ); case Err(:final error): Logger.error('Error during workspace expiry sync: ${error.code}'); // Extend validity so we retry later instead of hammering the backend. await config.update( - snapshot.copyWith( + synced.copyWith( workspace: TWorkspaceState( expiresAt: clock.now().add(_kExtension), - data: workspace.data, + data: synced.workspace?.data ?? workspace.data, ), ), ); @@ -135,7 +144,7 @@ class ExpiryTicker with WidgetsBindingObserver { if (user.data.userId != null && expiresAt != null && isNowExpired(expiresAt)) { - // Mirror RN's user ticker: extend the identified user's state by 30 min. + // Extend the identified user's state by 30 min. await config.update( current.copyWith( user: user.copyWith(expiresAt: clock.now().add(_kExtension)), diff --git a/packages/formbricks_flutter/lib/src/common/filter_surveys.dart b/packages/formbricks_flutter/lib/src/common/filter_surveys.dart new file mode 100644 index 0000000..6771f06 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/filter_surveys.dart @@ -0,0 +1,114 @@ +/// Survey eligibility filtering. +/// The eligible set is recomputed at every state-change point and persisted +/// as `TConfig.filteredSurveys`; the trigger path reads only that set. +library; + +import 'package:clock/clock.dart'; + +import '../types/config.dart'; +import '../types/survey.dart'; +import 'logger.dart'; +import 'utils.dart'; + +/// Whole days between [date1] and [date2], direction-agnostic. +int diffInDays(DateTime date1, DateTime date2) => + date1.difference(date2).inMilliseconds.abs() ~/ Duration.millisecondsPerDay; + +/// Whether [survey] targets a segment that carries filter rules. +bool surveyHasSegmentFilters(TSurvey survey) => + survey.segment?.hasFilters ?? false; + +/// Returns the surveys in [workspace] that [user] is eligible to see, in +/// three stages: displayOption, recontactDays, segment targeting. Malformed +/// entries are skipped. [now] is the recontact-stage test seam. +List filterSurveys( + TWorkspaceState workspace, + TUserState user, { + DateTime Function()? now, +}) { + final resolveNow = now ?? clock.now; + final settings = workspace.data.settings; + final userData = user.data; + + final filtered = workspace.data.surveys + .map((entry) => tryParseSurvey(entry, source: 'workspace state')) + .whereType() + .where((survey) => _passesDisplayOption(survey, userData)) + .where( + (survey) => + _passesRecontactDays(survey, userData, settings, resolveNow), + ) + .toList(); + + // A null or empty userId counts as anonymous. + final userId = userData.userId; + if (userId == null || userId.isEmpty) return _filterAnonymous(filtered); + if (userData.segments.isEmpty) return _filterIdentifiedWithoutSegments(); + + return filtered + .where( + (survey) => + survey.segment?.id != null && + userData.segments.contains(survey.segment!.id), + ) + .toList(); +} + +/// Stage 1 — displayOption +bool _passesDisplayOption(TSurvey survey, TUserData user) { + switch (survey.displayOption) { + case 'respondMultiple': + return true; + case 'displayOnce': + return user.displays.every((d) => d.surveyId != survey.id); + case 'displayMultiple': + return !user.responses.contains(survey.id); + case 'displaySome': + final limit = survey.displayLimit; + if (limit == null) return true; + if (user.responses.contains(survey.id)) return false; + return user.displays.where((d) => d.surveyId == survey.id).length < limit; + default: + // On an unknown/missing displayOption, exclude just this survey instead + // of letting a malformed entry kill the whole filter run. + Logger.debug( + 'Excluding survey "${survey.id}" with unknown displayOption ' + '"${survey.displayOption}"', + ); + return false; + } +} + +/// Stage 2 — recontactDays +bool _passesRecontactDays( + TSurvey survey, + TUserData user, + Map settings, + DateTime Function() now, +) { + final lastDisplayAt = user.lastDisplayAt; + if (lastDisplayAt == null) return true; + + // lastDisplayAt is shared across surveys; per-survey history only feeds + // the displayOption stage. + final surveyRecontactDays = survey.recontactDays; + if (surveyRecontactDays != null) { + return diffInDays(now(), lastDisplayAt) >= surveyRecontactDays; + } + + // Type check, not cast: non-num garbage from the backend behaves as unset. + final workspaceRecontactDays = settings['recontactDays']; + if (workspaceRecontactDays is num) { + return diffInDays(now(), lastDisplayAt) >= workspaceRecontactDays.toInt(); + } + + return true; +} + +/// Stage 3a — anonymous: segment-filtered surveys are hidden. +List _filterAnonymous(List surveys) => + surveys.where((survey) => !surveyHasSegmentFilters(survey)).toList(); + +/// Stage 3b — identified with no matched segments: +/// nothing is eligible. +List _filterIdentifiedWithoutSegments() => const []; diff --git a/packages/formbricks_flutter/lib/src/common/logger.dart b/packages/formbricks_flutter/lib/src/common/logger.dart index 53ec07d..ca5050d 100644 --- a/packages/formbricks_flutter/lib/src/common/logger.dart +++ b/packages/formbricks_flutter/lib/src/common/logger.dart @@ -14,8 +14,8 @@ enum LogLevel { /// Static logging facade over a hidden singleton. /// -/// Mirrors the RN SDK's `Logger` (same `🧱 Formbricks - [LEVEL] - msg` -/// format) but is configured once in `setup()` for deterministic behavior. +/// Emits `🧱 Formbricks - [LEVEL] - msg` lines and is configured once in +/// `setup()` for deterministic behavior. /// Routes through `dart:developer`'s `log()` rather than `print()` (the /// `avoid_print` lint is on) and never logs attribute values or other PII. class Logger { diff --git a/packages/formbricks_flutter/lib/src/common/result.dart b/packages/formbricks_flutter/lib/src/common/result.dart index 9e76904..e30e18e 100644 --- a/packages/formbricks_flutter/lib/src/common/result.dart +++ b/packages/formbricks_flutter/lib/src/common/result.dart @@ -1,7 +1,6 @@ /// A Rust-style result type: either an [Ok] value or an [Err] error. /// -/// Ported from the React Native SDK's `Result` (`types/error.ts`). Using a -/// `sealed` class lets callers `switch` exhaustively without a `default` arm: +/// A `sealed` class lets callers `switch` exhaustively without a `default` arm: /// /// ```dart /// switch (result) { diff --git a/packages/formbricks_flutter/lib/src/common/setup.dart b/packages/formbricks_flutter/lib/src/common/setup.dart index 52c251d..f83f18b 100644 --- a/packages/formbricks_flutter/lib/src/common/setup.dart +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -7,6 +7,7 @@ import '../types/errors.dart'; import 'api_client.dart'; import 'config.dart'; import 'expiry_ticker.dart'; +import 'filter_surveys.dart'; import 'logger.dart'; import 'result.dart'; import 'time.dart'; @@ -38,7 +39,7 @@ void resetSetupForTest() { /// /// Returns `Ok` on success and `Err(MissingFieldError)` for bad input. On a /// **first-setup** network/forbidden failure it persists the error-cooldown -/// state and **throws** [FormbricksSetupError] (matching the RN SDK). +/// state and **throws** [FormbricksSetupError]. /// /// [httpClient], [startTicker] and [logLevel] are overrides for tests and the /// playground. @@ -86,6 +87,11 @@ Future> setup({ final config = FormbricksConfig.instance; await config.init(); final existing = config.getOrNull(); + Logger.debug( + existing != null + ? 'Found existing configuration.' + : 'No existing configuration found.', + ); // Retry only after the stored first-setup cooldown expires for this target. if (existing != null && existing.status.isError) { @@ -125,20 +131,30 @@ Future> setup({ final result = await _syncExistingConfig(config, api, existing); if (result case Err()) return result; } else { + Logger.debug( + 'No valid configuration found. Resetting config and creating new one.', + ); await config.reset(); + Logger.debug('Syncing.'); final response = await api.getWorkspaceState(); switch (response) { case Ok(:final value): + final filteredSurveys = + filterSurveys(value, TUserState.defaultNoUserId); await config.update( TConfig( workspaceId: workspaceId, appUrl: normalizedAppUrl, workspace: value, user: TUserState.defaultNoUserId, - filteredSurveys: const [], + filteredSurveys: filteredSurveys.map((s) => s.toJson()).toList(), status: TStatus.success, ), ); + Logger.debug( + 'Fetched ${filteredSurveys.length} surveys during sync: ' + '${filteredSurveys.map((s) => s.id).join(', ')}', + ); case Err(:final error): await _handleErrorOnFirstSetup( config, @@ -150,6 +166,7 @@ Future> setup({ } if (startTicker) { + Logger.debug('Starting expiry ticker'); final ticker = ExpiryTicker(config: config, apiClient: api)..start(); _ticker = ticker; handedToTicker = true; @@ -196,19 +213,23 @@ Future> _syncExistingConfig( user = TUserState.defaultNoUserId; } + final filteredSurveys = filterSurveys(workspace, user); await config.update( existing.copyWith( workspace: workspace, user: user, - filteredSurveys: const [], + filteredSurveys: filteredSurveys.map((s) => s.toJson()).toList(), status: TStatus.success, ), ); + Logger.debug( + 'Fetched ${filteredSurveys.length} surveys during sync: ' + '${filteredSurveys.map((s) => s.id).join(', ')}', + ); return const Result.ok(null); } -/// Persists the error-cooldown state and throws [FormbricksSetupError]. Mirrors -/// RN's `handleErrorOnFirstSetup`. +/// Persists the error-cooldown state and throws [FormbricksSetupError]. Future _handleErrorOnFirstSetup( FormbricksConfig config, ApiErrorResponse error, { @@ -243,12 +264,10 @@ Future _handleErrorOnFirstSetup( ); } -/// Resets user state back to anonymous and persists it. +/// Resets user state to anonymous, refilters surveys, and persists both. /// /// Called when switching identity (`setUserId` to a different id) and on -/// `logout`. Mirrors RN's `tearDown` (`setup.ts:366–387`), minus the survey -/// refilter, which lands with the filtering ticket. No-op when no config is -/// loaded yet. +/// `logout`. No-op when no config is loaded yet. Future tearDown({FormbricksConfig? config}) async { final cfg = config ?? FormbricksConfig.instance; Logger.debug('Setting user state to default'); @@ -256,10 +275,16 @@ Future tearDown({FormbricksConfig? config}) async { final current = cfg.getOrNull(); if (current == null) return; + final workspace = current.workspace; await cfg.update( - current.copyWith(user: TUserState.defaultNoUserId), - // TODO(filtering ticket): recompute filteredSurveys against the default - // user state here (filterSurveys(workspace, defaultNoUserId)) — see ENG-1128. + current.copyWith( + user: TUserState.defaultNoUserId, + filteredSurveys: workspace == null + ? const [] + : filterSurveys(workspace, TUserState.defaultNoUserId) + .map((s) => s.toJson()) + .toList(), + ), ); } diff --git a/packages/formbricks_flutter/lib/src/common/time.dart b/packages/formbricks_flutter/lib/src/common/time.dart index 1debf0a..8cd9843 100644 --- a/packages/formbricks_flutter/lib/src/common/time.dart +++ b/packages/formbricks_flutter/lib/src/common/time.dart @@ -2,7 +2,7 @@ import 'package:clock/clock.dart'; /// Whether [expiresAt] is at or before "now". /// -/// Mirrors the RN SDK's `isNowExpired` (`now >= expirationDate`). Reads the -/// current time via `clock.now()` so expiry logic is deterministic under test +/// Compares as `now >= expirationDate`. Reads the current time via +/// `clock.now()` so expiry logic is deterministic under test /// (`withClock(...)`). bool isNowExpired(DateTime expiresAt) => !clock.now().isBefore(expiresAt); diff --git a/packages/formbricks_flutter/lib/src/common/utils.dart b/packages/formbricks_flutter/lib/src/common/utils.dart index 9d57dbd..0ac9db1 100644 --- a/packages/formbricks_flutter/lib/src/common/utils.dart +++ b/packages/formbricks_flutter/lib/src/common/utils.dart @@ -1,7 +1,30 @@ /// Survey rendering helpers. library; +import 'dart:math'; + import '../types/survey.dart'; +import 'logger.dart'; + +/// Parses a raw survey entry, returning null (and logging [source]) when +/// malformed. +TSurvey? tryParseSurvey(Object? entry, {required String source}) { + try { + return TSurvey.fromJson((entry as Map).cast()); + } catch (e) { + Logger.error('Skipping malformed survey in $source: $e'); + return null; + } +} + +/// Shared RNG for the display-percentage roll (not a security context, so +/// plain [Random] over `Random.secure()`). +final Random _sharedRandom = Random(); + +/// Rolls whether a survey should display given its [displayPercentage] +/// (0–100). [random] is the test seam. +bool shouldDisplayBasedOnPercentage(num displayPercentage, {Random? random}) => + (random ?? _sharedRandom).nextDouble() * 100 < displayPercentage; /// Returns the code of the survey's default language, or null when none is /// marked default. diff --git a/packages/formbricks_flutter/lib/src/survey/action.dart b/packages/formbricks_flutter/lib/src/survey/action.dart index 126b1d9..6bcacf7 100644 --- a/packages/formbricks_flutter/lib/src/survey/action.dart +++ b/packages/formbricks_flutter/lib/src/survey/action.dart @@ -1,14 +1,17 @@ -/// Tracks code actions against cached workspace surveys. +/// Tracks code actions against the filtered survey set. /// -/// This layer only matches action classes and leaves eligibility filtering to -/// the survey selection pipeline. +/// Matches action classes against `config.filteredSurveys` and applies the +/// per-display `displayPercentage` roll. library; +import 'dart:math'; + import 'package:connectivity_plus/connectivity_plus.dart'; import '../common/config.dart'; import '../common/logger.dart'; import '../common/result.dart'; +import '../common/utils.dart'; import '../types/action_class.dart'; import '../types/errors.dart'; import '../types/survey.dart'; @@ -22,32 +25,46 @@ Future _defaultIsConnected() async { return results.any((r) => r != ConnectivityResult.none); } -/// Marks [survey] for display. -void triggerSurvey(TSurvey survey, {SurveyStore? store}) => - (store ?? SurveyStore.instance).setSurvey(survey); +/// Marks [survey] for display unless the `displayPercentage` roll skips it. +/// [random] is the test seam. +void triggerSurvey(TSurvey survey, {SurveyStore? store, Random? random}) { + final displayPercentage = survey.displayPercentage; + if (displayPercentage != null && displayPercentage > 0) { + final shouldDisplay = + shouldDisplayBasedOnPercentage(displayPercentage, random: random); + if (!shouldDisplay) { + Logger.debug( + 'Survey display of "${survey.id}" skipped based on displayPercentage.', + ); + return; + } + } + (store ?? SurveyStore.instance).setSurvey(survey); +} -/// Triggers cached surveys whose action-class name matches [name]. +/// Triggers filtered surveys whose action-class name matches [name]. Future> trackAction( String name, { String? alias, FormbricksConfig? config, SurveyStore? store, + Random? random, }) async { final cfg = (config ?? FormbricksConfig.instance).get(); Logger.debug('Formbricks: Action "${alias ?? name}" tracked'); - final rawSurveys = cfg.workspace?.data.surveys ?? const []; - if (rawSurveys.isEmpty) { + final activeSurveys = cfg.filteredSurveys; + if (activeSurveys.isEmpty) { Logger.debug('No active surveys to display'); return const Result.ok(null); } - for (final entry in rawSurveys) { - final survey = _tryParseSurvey(entry); + for (final entry in activeSurveys) { + final survey = tryParseSurvey(entry, source: 'filtered surveys'); if (survey == null) continue; for (final trigger in survey.triggers) { if (trigger.actionClass.name == name) { - triggerSurvey(survey, store: store); + triggerSurvey(survey, store: store, random: random); } } } @@ -117,15 +134,6 @@ Future> track( } } -TSurvey? _tryParseSurvey(Object? entry) { - try { - return TSurvey.fromJson((entry as Map).cast()); - } catch (e) { - Logger.error('Skipping malformed survey in workspace state: $e'); - return null; - } -} - TActionClass? _tryParseActionClass(Object? entry) { try { return TActionClass.fromJson((entry as Map).cast()); diff --git a/packages/formbricks_flutter/lib/src/types/config.dart b/packages/formbricks_flutter/lib/src/types/config.dart index e6fbd09..0404216 100644 --- a/packages/formbricks_flutter/lib/src/types/config.dart +++ b/packages/formbricks_flutter/lib/src/types/config.dart @@ -1,7 +1,6 @@ /// Persisted SDK configuration models. /// -/// These mirror the React Native SDK's `types/config.ts`. The one rule that -/// matters: **`DateTime` lives in memory, ISO-8601 strings live on the wire and +/// The one rule that matters: **`DateTime` lives in memory, ISO-8601 strings live on the wire and /// on disk**, and the conversion happens *only* inside the `fromJson` / `toJson` /// methods here. No `DateTime.parse` anywhere else in the codebase. /// @@ -149,8 +148,7 @@ class TUserState { /// The user data slice. final TUserData data; - /// The default anonymous user state (no user id, never expires). Mirrors RN's - /// `DEFAULT_USER_STATE_NO_USER_ID`. + /// The default anonymous user state (no user id, never expires). static const TUserState defaultNoUserId = TUserState( expiresAt: null, data: TUserData(), diff --git a/packages/formbricks_flutter/lib/src/types/errors.dart b/packages/formbricks_flutter/lib/src/types/errors.dart index d88897c..20cdc63 100644 --- a/packages/formbricks_flutter/lib/src/types/errors.dart +++ b/packages/formbricks_flutter/lib/src/types/errors.dart @@ -1,5 +1,5 @@ /// Stable error codes used across the SDK. The [wire] value matches the string -/// the backend and the React Native SDK use, so logs and tests stay comparable. +/// the backend uses, so logs and tests stay comparable. enum FormbricksErrorCode { /// A required input field was missing or empty. missingField('missing_field'), @@ -116,7 +116,7 @@ final class InternalError extends FormbricksError { } /// Thrown when the very first [setup] attempt fails and the SDK is placed into -/// the error-cooldown state. Mirrors the RN SDK throwing on first-setup failure. +/// the error-cooldown state. final class FormbricksSetupError extends FormbricksError { /// Creates a setup error. Carries the underlying [code] (network/forbidden). FormbricksSetupError({ diff --git a/packages/formbricks_flutter/lib/src/types/survey.dart b/packages/formbricks_flutter/lib/src/types/survey.dart index 6150dc9..f267f1d 100644 --- a/packages/formbricks_flutter/lib/src/types/survey.dart +++ b/packages/formbricks_flutter/lib/src/types/survey.dart @@ -18,6 +18,11 @@ class TSurvey { required this.delay, this.styling, this.projectOverwrites, + this.displayOption, + this.displayLimit, + this.recontactDays, + this.displayPercentage, + this.segment, required Map raw, }) : _raw = raw; @@ -48,6 +53,15 @@ class TSurvey { : TProjectOverwrites.fromJson( (json['projectOverwrites'] as Map).cast(), ), + displayOption: json['displayOption'] as String?, + displayLimit: (json['displayLimit'] as num?)?.toInt(), + recontactDays: (json['recontactDays'] as num?)?.toInt(), + displayPercentage: json['displayPercentage'] as num?, + segment: json['segment'] == null + ? null + : TSurveySegment.fromJson( + (json['segment'] as Map).cast(), + ), raw: json, ); @@ -70,6 +84,23 @@ class TSurvey { /// Per-project overrides for placement / click-outside / overlay. final TProjectOverwrites? projectOverwrites; + /// How often the survey may be shown (`respondMultiple` / `displayOnce` / + /// `displayMultiple` / `displaySome`); unknown values make it ineligible. + final String? displayOption; + + /// Max number of displays for `'displaySome'`, or null for unlimited. + final int? displayLimit; + + /// Days since the last display before showing again, or null to fall back + /// to the workspace `settings.recontactDays`. + final int? recontactDays; + + /// Percentage of trigger hits that display the survey; null/0 always shows. + final num? displayPercentage; + + /// The segment targeting this survey, or null when untargeted. + final TSurveySegment? segment; + final Map _raw; /// Whether the survey is available in more than one language. @@ -79,6 +110,31 @@ class TSurvey { Map toJson() => _raw; } +/// The minimal segment shape read for targeting: `{ id, hasFilters }`. +/// Tolerates the legacy cached shape carrying a full `filters` array. +class TSurveySegment { + /// Creates a segment. + const TSurveySegment({this.id, required this.hasFilters}); + + /// Builds a segment from decoded JSON. + factory TSurveySegment.fromJson(Map json) { + final hasFilters = json['hasFilters']; + final filters = json['filters']; + return TSurveySegment( + id: json['id'] as String?, + hasFilters: hasFilters is bool + ? hasFilters + : filters is List && filters.isNotEmpty, + ); + } + + /// The segment id matched against `user.segments`, or null. + final String? id; + + /// Whether the segment carries filter rules. + final bool hasFilters; +} + /// A survey trigger. Only the action-class name is read by the SDK. class TSurveyTrigger { /// Creates a trigger. diff --git a/packages/formbricks_flutter/lib/src/user/attribute.dart b/packages/formbricks_flutter/lib/src/user/attribute.dart index b69567d..9902013 100644 --- a/packages/formbricks_flutter/lib/src/user/attribute.dart +++ b/packages/formbricks_flutter/lib/src/user/attribute.dart @@ -1,7 +1,6 @@ /// Contact attributes: `setAttributes` and `setLanguage`. /// -/// Ports the RN `lib/user/attribute.ts`. The backend infers each attribute's -/// type from the JSON value type, so values cross the wire with their natural +/// The backend infers each attribute's type from the JSON value type, so values cross the wire with their natural /// type: `String` stays a string, `num` stays a number, and `DateTime` is /// converted to an ISO-8601 string at this one boundary (plan §7 #4). library; @@ -39,8 +38,8 @@ Future> setAttributes( /// Sets the contact's preferred [language]. /// -/// Routes through [setAttributes] exactly as RN does — this is what makes the -/// "language without a userId updates local config only" path reachable. +/// Routes through [setAttributes] — this is what makes the "language without a +/// userId updates local config only" path reachable. Future> setLanguage( String language, { UpdateQueue? queue, diff --git a/packages/formbricks_flutter/lib/src/user/update_queue.dart b/packages/formbricks_flutter/lib/src/user/update_queue.dart index 773e0c8..fe5fee6 100644 --- a/packages/formbricks_flutter/lib/src/user/update_queue.dart +++ b/packages/formbricks_flutter/lib/src/user/update_queue.dart @@ -1,7 +1,6 @@ /// Debounced user-update coalescer. /// -/// Ports the RN `UpdateQueue` (`lib/user/update-queue.ts`). Rapid identity and -/// attribute changes are merged into a single accumulator and flushed once, +/// Rapid identity and attribute changes are merged into a single accumulator and flushed once, /// 500 ms after the *last* call, into one `POST /api/v2/client/{id}/user`. /// /// Ordering of the public API is provided by the [CommandQueue], not here — the @@ -15,6 +14,7 @@ import 'package:flutter/foundation.dart'; import '../common/api_client.dart'; import '../common/config.dart'; +import '../common/filter_surveys.dart'; import '../common/logger.dart'; import '../common/result.dart'; import '../types/config.dart'; @@ -25,7 +25,7 @@ class _PendingUpdates { _PendingUpdates({required this.userId, this.attributes}); /// The user id to apply. May be empty when only attributes were queued and no - /// userId is resolvable yet (mirrors RN's `?? ""` fallback). + /// userId is resolvable yet. final String userId; /// The attributes to apply (numbers preserved as numbers; dates already ISO). @@ -82,7 +82,7 @@ class UpdateQueue { /// Merges [attributes] into the pending updates. /// /// The effective userId is resolved from the pending updates first, then from - /// the persisted config (mirrors `update-queue.ts:40`). Later keys win. + /// the persisted config. Later keys win. void updateAttributes(Map attributes) { final pendingUserId = _updates?.userId; final userId = (pendingUserId != null && pendingUserId.isNotEmpty) @@ -116,6 +116,7 @@ class UpdateQueue { await _flush(); if (c != null && !c.isCompleted) c.complete(); } catch (error, stackTrace) { + Logger.error('Failed to process updates: $error'); if (c != null && !c.isCompleted) c.completeError(error, stackTrace); } }); @@ -139,7 +140,7 @@ class UpdateQueue { _updates = null; } - /// The debounced handler (port of `update-queue.ts:154–195`). + /// The debounced handler. Future _flush() async { final pending = _updates; if (pending == null) return; @@ -180,7 +181,7 @@ class UpdateQueue { } /// Writes a queued `language` straight into local config (no API call) and - /// strips it from [attributes] (port of `update-queue.ts:68–96`). + /// strips it from [attributes]. Future> _handleLanguageWithoutUserId( Map attributes, ) async { @@ -200,8 +201,8 @@ class UpdateQueue { return Map.from(attributes)..remove('language'); } - /// Sends the batch to the backend and persists the returned state (port of - /// `update.ts:63–123`). No-op without a userId; no retry on failure. + /// Sends the batch to the backend and persists the returned state. + /// No-op without a userId; no retry on failure. Future _sendUpdates( String? userId, Map attributes, @@ -234,9 +235,19 @@ class UpdateQueue { value.messages?.forEach((m) => Logger.debug('User update message: $m')); final hasWarnings = value.errors?.isNotEmpty ?? false; - await _config.update(_config.get().copyWith(user: value.state)); - // TODO(filtering ticket): recompute filteredSurveys against - // (workspace, value.state) here — see ENG-1128. + // Persist the synced user and refilter in the same write. + final current = _config.get(); + final workspace = current.workspace; + await _config.update( + current.copyWith( + user: value.state, + filteredSurveys: workspace == null + ? const [] + : filterSurveys(workspace, value.state) + .map((s) => s.toJson()) + .toList(), + ), + ); if (!hasWarnings) Logger.debug('Updates sent successfully'); } diff --git a/packages/formbricks_flutter/lib/src/user/user.dart b/packages/formbricks_flutter/lib/src/user/user.dart index e082d8a..563b307 100644 --- a/packages/formbricks_flutter/lib/src/user/user.dart +++ b/packages/formbricks_flutter/lib/src/user/user.dart @@ -1,7 +1,6 @@ /// User identity: `setUserId` and `logout`. /// -/// Ports the RN `lib/user/user.ts`. Queues identity changes through the -/// [UpdateQueue] (fire-and-forget) and resets prior state via [tearDown] when +/// Queues identity changes through the [UpdateQueue] (fire-and-forget) and resets prior state via [tearDown] when /// switching to a different user. library; @@ -43,7 +42,7 @@ Future> setUserId( 'Different userId is being set, cleaning up previous user state', ); // Drop anything queued for the previous identity so it can't be adopted by - // the new user. Diverges from RN, which leaves the buffer intact. + // the new user. q.clear(); await tearDown(config: cfg); } @@ -63,7 +62,7 @@ Future> logout({ }) async { Logger.debug('Logging out and cleaning user state'); // Drop any queued update so a debounced flush can't re-identify the user - // after logout. Diverges from RN, which leaves the buffer intact. + // after logout. (queue ?? UpdateQueue.instance).clear(); await tearDown(config: config ?? FormbricksConfig.instance); return const Result.ok(null); diff --git a/packages/formbricks_flutter/lib/src/widgets/survey_webview.dart b/packages/formbricks_flutter/lib/src/widgets/survey_webview.dart index fb0d663..a3c684d 100644 --- a/packages/formbricks_flutter/lib/src/widgets/survey_webview.dart +++ b/packages/formbricks_flutter/lib/src/widgets/survey_webview.dart @@ -12,6 +12,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/widgets.dart'; import '../common/config.dart'; +import '../common/filter_surveys.dart'; import '../common/logger.dart'; import '../common/utils.dart'; import '../survey/survey_store.dart'; @@ -174,7 +175,7 @@ class _SurveyWebViewState extends State { String appUrl, ) { // Builder so the keyboard inset is read in a context that rebuilds on - // show/hide (RN's KeyboardAvoidingView equivalent). + // keyboard show/hide. return Builder( builder: (ctx) => Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom), @@ -220,31 +221,37 @@ class _SurveyWebViewState extends State { ...current.user.data.displays, TDisplay(surveyId: widget.survey.id, createdAt: now), ]; - await _config.update( - current.copyWith( - user: current.user.copyWith( - data: current.user.data.copyWith( - displays: displays, - lastDisplayAt: now, - ), - ), + final user = current.user.copyWith( + data: current.user.data.copyWith( + displays: displays, + lastDisplayAt: now, ), ); + await _config.update( + current.copyWith(user: user, filteredSurveys: _refilter(current, user)), + ); } Future _recordResponse() async { final current = _config.getOrNull(); if (current == null) return; final responses = [...current.user.data.responses, widget.survey.id]; + final user = current.user.copyWith( + data: current.user.data.copyWith(responses: responses), + ); await _config.update( - current.copyWith( - user: current.user.copyWith( - data: current.user.data.copyWith(responses: responses), - ), - ), + current.copyWith(user: user, filteredSurveys: _refilter(current, user)), ); } + /// Refilters the eligible set against the just-updated [user], so e.g. a + /// shown `displayOnce` survey stops triggering immediately. + List _refilter(TConfig config, TUserState user) { + final workspace = config.workspace; + if (workspace == null) return const []; + return filterSurveys(workspace, user).map((s) => s.toJson()).toList(); + } + void _closeSurvey({bool alreadyDismissed = false}) { if (_closing) return; _closing = true; @@ -256,10 +263,13 @@ class _SurveyWebViewState extends State { _routeOpen = false; // Queue the close write after display/response updates so bridge events - // cannot overtake each other. + // cannot overtake each other. The close refilters too. _enqueueConfigOp(() async { final current = _config.getOrNull(); - if (current != null) await _config.update(current); + if (current == null) return; + await _config.update( + current.copyWith(filteredSurveys: _refilter(current, current.user)), + ); }); _store.resetSurvey(); diff --git a/packages/formbricks_flutter/test/common/expiry_ticker_test.dart b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart index be46bad..c5c1ca1 100644 --- a/packages/formbricks_flutter/test/common/expiry_ticker_test.dart +++ b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart @@ -12,17 +12,33 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; -String _envBody(String expiresAt) => jsonEncode({ +String _envBody( + String expiresAt, { + List> surveys = const [], +}) => + jsonEncode({ 'data': { 'expiresAt': expiresAt, 'data': { - 'surveys': [], + 'surveys': surveys, 'actionClasses': [], 'settings': {}, }, }, }); +Map _surveyJson( + String id, { + Map? segment, +}) => + { + 'id': id, + 'displayOption': 'respondMultiple', + 'triggers': [], + 'languages': [], + if (segment != null) 'segment': segment, + }; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -106,6 +122,7 @@ void main() { required DateTime workspaceExpiry, DateTime? userExpiry, String? userId, + List segments = const [], }) => TConfig( workspaceId: 'w', @@ -116,7 +133,7 @@ void main() { ), user: TUserState( expiresAt: userExpiry, - data: TUserData(userId: userId), + data: TUserData(userId: userId, segments: segments), ), status: TStatus.success, ); @@ -145,6 +162,184 @@ void main() { expect(FormbricksConfig.instance.get().workspace!.expiresAt.year, 2100); }); + test('refetch recomputes filteredSurveys for an anonymous user', () async { + await seed( + configWith(workspaceExpiry: now.subtract(const Duration(minutes: 1))), + ); + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient( + (_) async => http.Response( + _envBody( + '2100-01-01T00:00:00.000', + surveys: [ + _surveyJson('plain'), + _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ], + ), + 200, + ), + ), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + expect( + FormbricksConfig.instance + .get() + .filteredSurveys + .map((e) => (e as Map)['id']) + .toList(), + ['plain'], + reason: 'segment-filtered surveys drop without a userId', + ); + }); + + test('refetch recomputes filteredSurveys against the identified user', + () async { + await seed( + configWith( + workspaceExpiry: now.subtract(const Duration(minutes: 1)), + userId: 'u1', + segments: ['seg_a'], + ), + ); + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient( + (_) async => http.Response( + _envBody( + '2100-01-01T00:00:00.000', + surveys: [ + _surveyJson('plain'), + _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ], + ), + 200, + ), + ), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + expect( + FormbricksConfig.instance + .get() + .filteredSurveys + .map((e) => (e as Map)['id']) + .toList(), + ['gated'], + reason: 'identified users only see segment-matched surveys', + ); + }); + + test('a config write landing during the refetch is not clobbered (Ok)', + () async { + await seed( + configWith(workspaceExpiry: now.subtract(const Duration(minutes: 1))), + ); + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async { + // Concurrent writer while the request is on the wire. + final mid = FormbricksConfig.instance.get(); + await FormbricksConfig.instance.update( + mid.copyWith( + user: const TUserState( + expiresAt: null, + data: TUserData(userId: 'u1', segments: ['seg_a']), + ), + ), + ); + return http.Response( + _envBody( + '2100-01-01T00:00:00.000', + surveys: [ + _surveyJson('plain'), + _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ], + ), + 200, + ); + }), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + final config = FormbricksConfig.instance.get(); + expect( + config.user.data.userId, + 'u1', + reason: 'the concurrent user write must survive the workspace persist', + ); + expect( + config.filteredSurveys.map((e) => (e as Map)['id']).toList(), + ['gated'], + reason: 'the refilter must see the concurrently-written user', + ); + }); + + test('a config write landing during a failed refetch is kept (Err)', + () async { + await seed( + configWith(workspaceExpiry: now.subtract(const Duration(minutes: 1))), + ); + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async { + final mid = FormbricksConfig.instance.get(); + await FormbricksConfig.instance.update( + mid.copyWith( + user: const TUserState( + expiresAt: null, + data: TUserData(userId: 'u1'), + ), + ), + ); + return http.Response('{}', 500); + }), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + final config = FormbricksConfig.instance.get(); + expect(config.user.data.userId, 'u1', reason: 'concurrent write kept'); + expect( + config.workspace!.expiresAt, + now.add(const Duration(minutes: 30)), + reason: 'validity still extended for the retry', + ); + }); + test('extends workspace validity when the refetch fails', () async { await seed( configWith(workspaceExpiry: now.subtract(const Duration(minutes: 1))), diff --git a/packages/formbricks_flutter/test/common/filter_surveys_test.dart b/packages/formbricks_flutter/test/common/filter_surveys_test.dart new file mode 100644 index 0000000..1ce72f9 --- /dev/null +++ b/packages/formbricks_flutter/test/common/filter_surveys_test.dart @@ -0,0 +1,536 @@ +import 'package:clock/clock.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/filter_surveys.dart'; +import 'package:formbricks_flutter/src/common/logger.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; +import 'package:formbricks_flutter/src/types/survey.dart'; + +Map _survey( + String id, { + Object? displayOption = 'respondMultiple', + int? displayLimit, + int? recontactDays, + Map? segment, +}) => + { + 'id': id, + if (displayOption != null) 'displayOption': displayOption, + if (displayLimit != null) 'displayLimit': displayLimit, + if (recontactDays != null) 'recontactDays': recontactDays, + if (segment != null) 'segment': segment, + }; + +TWorkspaceState _workspace( + List surveys, { + Map settings = const {}, +}) => + TWorkspaceState( + expiresAt: DateTime(2100), + data: TWorkspaceData(surveys: surveys, settings: settings), + ); + +TUserState _user({ + String? userId, + List segments = const [], + List displays = const [], + List responses = const [], + DateTime? lastDisplayAt, +}) => + TUserState( + expiresAt: null, + data: TUserData( + userId: userId, + segments: segments, + displays: displays, + responses: responses, + lastDisplayAt: lastDisplayAt, + ), + ); + +List _ids(List surveys) => surveys.map((s) => s.id).toList(); + +void main() { + final now = DateTime(2026, 6, 10, 12); + DateTime fixedNow() => now; + + setUp(Logger.resetInstance); + + group('diffInDays', () { + test('same instant → 0', () { + expect(diffInDays(now, now), 0); + }); + + test('less than a day → 0', () { + expect( + diffInDays(now, now.subtract(const Duration(hours: 23, minutes: 59))), + 0, + ); + }); + + test('exactly one day → 1', () { + expect(diffInDays(now, now.subtract(const Duration(days: 1))), 1); + }); + + test('partial days floor down', () { + expect( + diffInDays(now, now.subtract(const Duration(days: 2, hours: 12))), + 2, + ); + }); + + test('is direction-agnostic', () { + final other = now.add(const Duration(days: 3)); + expect(diffInDays(now, other), 3); + expect(diffInDays(other, now), 3); + }); + }); + + group('surveyHasSegmentFilters', () { + TSurvey parse(Map json) => TSurvey.fromJson(json); + + test('no segment → false', () { + expect(surveyHasSegmentFilters(parse({'id': 's'})), isFalse); + }); + + test('boolean hasFilters is taken verbatim', () { + expect( + surveyHasSegmentFilters( + parse({ + 'id': 's', + 'segment': {'id': 'seg', 'hasFilters': true}, + }), + ), + isTrue, + ); + expect( + surveyHasSegmentFilters( + parse({ + 'id': 's', + 'segment': {'id': 'seg', 'hasFilters': false}, + }), + ), + isFalse, + ); + }); + + test('legacy non-empty filters array → true', () { + expect( + surveyHasSegmentFilters( + parse({ + 'id': 's', + 'segment': { + 'id': 'seg', + 'filters': [ + {'connector': null}, + ], + }, + }), + ), + isTrue, + ); + }); + + test('legacy empty filters array → false', () { + expect( + surveyHasSegmentFilters( + parse({ + 'id': 's', + 'segment': {'id': 'seg', 'filters': []}, + }), + ), + isFalse, + ); + }); + + test('non-list filters value → false', () { + expect( + surveyHasSegmentFilters( + parse({ + 'id': 's', + 'segment': {'id': 'seg', 'filters': 'bogus'}, + }), + ), + isFalse, + ); + }); + }); + + group('filterSurveys — displayOption stage', () { + final display = TDisplay(surveyId: 's1', createdAt: now); + + test('respondMultiple is always kept', () { + final result = filterSurveys( + _workspace([_survey('s1')]), + _user(displays: [display], responses: ['s1']), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('displayOnce is dropped once a display exists', () { + final workspace = _workspace([ + _survey('s1', displayOption: 'displayOnce'), + ]); + expect( + _ids( + filterSurveys(workspace, _user(displays: [display]), now: fixedNow), + ), + isEmpty, + ); + expect( + _ids(filterSurveys(workspace, _user(), now: fixedNow)), + ['s1'], + ); + }); + + test('displayOnce ignores displays of other surveys', () { + final result = filterSurveys( + _workspace([_survey('s1', displayOption: 'displayOnce')]), + _user(displays: [TDisplay(surveyId: 'other', createdAt: now)]), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('displayMultiple is dropped once a response exists', () { + final workspace = _workspace([ + _survey('s1', displayOption: 'displayMultiple'), + ]); + expect( + _ids(filterSurveys(workspace, _user(responses: ['s1']), now: fixedNow)), + isEmpty, + ); + // Displays alone don't matter for displayMultiple. + expect( + _ids( + filterSurveys(workspace, _user(displays: [display]), now: fixedNow), + ), + ['s1'], + ); + }); + + test('displaySome without a limit is kept', () { + final result = filterSurveys( + _workspace([_survey('s1', displayOption: 'displaySome')]), + _user(displays: [display, display], responses: ['s1']), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('displaySome with a limit is dropped once a response exists', () { + final result = filterSurveys( + _workspace([ + _survey('s1', displayOption: 'displaySome', displayLimit: 5), + ]), + _user(responses: ['s1']), + now: fixedNow, + ); + expect(_ids(result), isEmpty); + }); + + test('displaySome keeps below the displayLimit and drops at it', () { + final workspace = _workspace([ + _survey('s1', displayOption: 'displaySome', displayLimit: 2), + ]); + expect( + _ids( + filterSurveys(workspace, _user(displays: [display]), now: fixedNow), + ), + ['s1'], + reason: '1 display < limit 2', + ); + expect( + _ids( + filterSurveys( + workspace, + _user(displays: [display, display]), + now: fixedNow, + ), + ), + isEmpty, + reason: '2 displays == limit 2', + ); + }); + + test('an unknown displayOption excludes the survey without throwing', () { + final result = filterSurveys( + _workspace([ + _survey('bad', displayOption: 'weird'), + _survey('good'), + ]), + _user(), + now: fixedNow, + ); + expect(_ids(result), ['good']); + }); + + test('a missing displayOption excludes the survey without throwing', () { + final result = filterSurveys( + _workspace([ + _survey('bad', displayOption: null), + _survey('good'), + ]), + _user(), + now: fixedNow, + ); + expect(_ids(result), ['good']); + }); + }); + + group('filterSurveys — recontactDays stage', () { + test('no prior display → kept regardless of recontactDays', () { + final result = filterSurveys( + _workspace( + [_survey('s1', recontactDays: 30)], + settings: {'recontactDays': 30}, + ), + _user(), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('survey-level recontactDays boundary: diff == days is kept', () { + final result = filterSurveys( + _workspace([_survey('s1', recontactDays: 3)]), + _user(lastDisplayAt: now.subtract(const Duration(days: 3))), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('survey-level recontactDays: diff < days is dropped', () { + final result = filterSurveys( + _workspace([_survey('s1', recontactDays: 3)]), + _user(lastDisplayAt: now.subtract(const Duration(days: 2, hours: 23))), + now: fixedNow, + ); + expect(_ids(result), isEmpty); + }); + + test('recontactDays of 0 shows again immediately', () { + final result = filterSurveys( + _workspace([_survey('s1', recontactDays: 0)]), + _user(lastDisplayAt: now.subtract(const Duration(hours: 1))), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('survey-level value takes precedence over workspace settings', () { + final result = filterSurveys( + _workspace( + [_survey('s1', recontactDays: 1)], + settings: {'recontactDays': 10}, + ), + _user(lastDisplayAt: now.subtract(const Duration(days: 2))), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('falls back to workspace settings.recontactDays', () { + final workspace = _workspace( + [_survey('s1')], + settings: {'recontactDays': 5}, + ); + expect( + _ids( + filterSurveys( + workspace, + _user(lastDisplayAt: now.subtract(const Duration(days: 5))), + now: fixedNow, + ), + ), + ['s1'], + ); + expect( + _ids( + filterSurveys( + workspace, + _user(lastDisplayAt: now.subtract(const Duration(days: 4))), + now: fixedNow, + ), + ), + isEmpty, + ); + }); + + test('non-num workspace recontactDays is ignored, not thrown', () { + for (final garbage in ['7', true]) { + final result = filterSurveys( + _workspace([_survey('s1')], settings: {'recontactDays': garbage}), + _user(lastDisplayAt: now.subtract(const Duration(minutes: 1))), + now: fixedNow, + ); + expect(_ids(result), ['s1'], reason: 'garbage value: $garbage'); + } + }); + + test('neither survey nor workspace recontactDays set → kept', () { + final result = filterSurveys( + _workspace([_survey('s1')]), + _user(lastDisplayAt: now.subtract(const Duration(minutes: 1))), + now: fixedNow, + ); + expect(_ids(result), ['s1']); + }); + + test('defaults to clock.now when no now is injected', () { + withClock(Clock.fixed(now), () { + final workspace = _workspace([_survey('s1', recontactDays: 3)]); + expect( + _ids( + filterSurveys( + workspace, + _user(lastDisplayAt: now.subtract(const Duration(days: 3))), + ), + ), + ['s1'], + ); + expect( + _ids( + filterSurveys( + workspace, + _user(lastDisplayAt: now.subtract(const Duration(days: 2))), + ), + ), + isEmpty, + ); + }); + }); + }); + + group('filterSurveys — segment stage', () { + final unfiltered = _survey('plain'); + final filtered = _survey( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ); + + test('anonymous: segment-filtered surveys are dropped, others kept', () { + final result = filterSurveys( + _workspace([unfiltered, filtered]), + _user(), + now: fixedNow, + ); + expect(_ids(result), ['plain']); + }); + + test('anonymous: legacy filters shapes are honored', () { + final result = filterSurveys( + _workspace([ + _survey( + 'legacy-gated', + segment: { + 'id': 'seg_a', + 'filters': [ + {'connector': null}, + ], + }, + ), + _survey( + 'legacy-empty', + segment: {'id': 'seg_b', 'filters': []}, + ), + ]), + _user(), + now: fixedNow, + ); + expect(_ids(result), ['legacy-empty']); + }); + + test('an empty-string userId is treated as anonymous', () { + final result = filterSurveys( + _workspace([unfiltered, filtered]), + _user(userId: ''), + now: fixedNow, + ); + expect(_ids(result), ['plain']); + }); + + test('identified with no matched segments → nothing is eligible', () { + final result = filterSurveys( + _workspace([unfiltered, filtered]), + _user(userId: 'u1'), + now: fixedNow, + ); + expect(result, isEmpty); + }); + + test('identified: only surveys whose segment id matched are kept', () { + final result = filterSurveys( + _workspace([ + filtered, + _survey('other', segment: {'id': 'seg_b', 'hasFilters': true}), + unfiltered, + ]), + _user(userId: 'u1', segments: ['seg_a']), + now: fixedNow, + ); + expect(_ids(result), ['gated']); + }); + + test('identified: a null segment id never matches', () { + final result = filterSurveys( + _workspace([ + _survey('idless', segment: {'hasFilters': true}), + ]), + _user(userId: 'u1', segments: ['seg_a']), + now: fixedNow, + ); + expect(result, isEmpty); + }); + }); + + group('filterSurveys — composition', () { + test('stages compose: passing earlier stages still fails segments', () { + final result = filterSurveys( + _workspace([ + _survey('gated', segment: {'id': 'seg_a', 'hasFilters': true}), + ]), + _user(), + now: fixedNow, + ); + expect(result, isEmpty); + }); + + test('an earlier stage drops before segments can keep', () { + // Segment matches, but the survey was already displayed once. + final result = filterSurveys( + _workspace([ + _survey( + 's1', + displayOption: 'displayOnce', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ]), + _user( + userId: 'u1', + segments: ['seg_a'], + displays: [TDisplay(surveyId: 's1', createdAt: now)], + ), + now: fixedNow, + ); + expect(result, isEmpty); + }); + + test('a malformed survey entry is skipped, valid ones survive', () { + final result = filterSurveys( + _workspace([ + {'noId': true}, + 'not even a map', + _survey('good'), + ]), + _user(), + now: fixedNow, + ); + expect(_ids(result), ['good']); + }); + + test('empty survey list → empty result', () { + expect(filterSurveys(_workspace([]), _user(), now: fixedNow), isEmpty); + }); + }); +} diff --git a/packages/formbricks_flutter/test/common/setup_test.dart b/packages/formbricks_flutter/test/common/setup_test.dart index dc79e62..697a573 100644 --- a/packages/formbricks_flutter/test/common/setup_test.dart +++ b/packages/formbricks_flutter/test/common/setup_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks_flutter/src/common/config.dart'; import 'package:formbricks_flutter/src/common/logger.dart'; import 'package:formbricks_flutter/src/common/result.dart'; -import 'package:formbricks_flutter/src/common/setup.dart'; +import 'package:formbricks_flutter/src/common/setup.dart' hide tearDown; +import 'package:formbricks_flutter/src/common/setup.dart' as fb_setup + show tearDown; import 'package:formbricks_flutter/src/types/config.dart'; import 'package:formbricks_flutter/src/types/errors.dart'; import 'package:http/http.dart' as http; @@ -15,25 +17,37 @@ import 'package:shared_preferences/shared_preferences.dart'; const _appUrl = 'https://app.formbricks.com'; const _workspaceId = 'wsp_1'; -String _envBody() => jsonEncode({ +String _envBody({List> surveys = const []}) => jsonEncode({ 'data': { 'expiresAt': '2100-01-01T00:00:00.000', 'data': { - 'surveys': [], + 'surveys': surveys, 'actionClasses': [], 'settings': {}, }, }, }); -String _userBody() => jsonEncode({ +Map _surveyJson( + String id, { + Map? segment, +}) => + { + 'id': id, + 'displayOption': 'respondMultiple', + 'triggers': [], + 'languages': [], + if (segment != null) 'segment': segment, + }; + +String _userBody({List segments = const []}) => jsonEncode({ 'data': { 'state': { 'expiresAt': null, 'data': { 'userId': 'u1', 'contactId': null, - 'segments': [], + 'segments': segments, 'displays': [], 'responses': [], 'lastDisplayAt': null, @@ -78,6 +92,159 @@ void main() { }, ); + test( + 'new-config setup populates filteredSurveys against the anonymous user', + () async { + final mock = MockClient( + (_) async => http.Response( + _envBody( + surveys: [ + _surveyJson('eligible'), + _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ], + ), + 200, + ), + ); + + final result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + + expect(result.isOk, isTrue); + final filtered = FormbricksConfig.instance.get().filteredSurveys; + expect( + filtered.map((e) => (e as Map)['id']).toList(), + ['eligible'], + ); + }, + ); + + test( + 'matching-config sync recomputes filteredSurveys from the cache', + () async { + final now = DateTime(2026, 6, 1, 12); + final cached = TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + workspace: TWorkspaceState( + expiresAt: now.add(const Duration(hours: 1)), + data: TWorkspaceData( + surveys: [ + _surveyJson('eligible'), + _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ], + ), + ), + // Stale empty set the sync must repopulate. + filteredSurveys: const [], + status: TStatus.success, + ); + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode(cached.toJson()), + }); + FormbricksConfig.resetInstance(); + + var calls = 0; + final mock = MockClient((_) async { + calls++; + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock(Clock.fixed(now), () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }); + + expect(result.isOk, isTrue); + expect(calls, 0, reason: 'valid workspace + anonymous user → no HTTP'); + expect( + FormbricksConfig.instance + .get() + .filteredSurveys + .map((e) => (e as Map)['id']) + .toList(), + ['eligible'], + ); + }, + ); + + test( + 'matching-config sync filters against the backend-resolved user', + () async { + final now = DateTime(2026, 6, 1, 12); + final cached = TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + workspace: TWorkspaceState( + expiresAt: now.add(const Duration(hours: 1)), + data: TWorkspaceData( + surveys: [ + _surveyJson('plain'), + _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ), + ], + ), + ), + // Expired identified user: filtering must use the refreshed segments. + user: TUserState( + expiresAt: now.subtract(const Duration(minutes: 1)), + data: const TUserData(userId: 'u1'), + ), + status: TStatus.success, + ); + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode(cached.toJson()), + }); + FormbricksConfig.resetInstance(); + + final mock = MockClient((req) async { + if (req.url.path.endsWith('/user')) { + return http.Response(_userBody(segments: ['seg_a']), 200); + } + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock(Clock.fixed(now), () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }); + + expect(result.isOk, isTrue); + expect( + FormbricksConfig.instance + .get() + .filteredSurveys + .map((e) => (e as Map)['id']) + .toList(), + ['gated'], + reason: 'the resolved user matched seg_a, so only the gated survey ' + 'is eligible', + ); + }, + ); + test('missing workspaceId returns Err(MissingFieldError)', () async { final mock = MockClient((_) async => http.Response(_envBody(), 200)); final result = await setup( @@ -465,4 +632,70 @@ void main() { ); expect(Logger.level, LogLevel.error); }); + + group('tearDown', () { + Future seedConfig(TConfig cfg) async { + final c = FormbricksConfig.instance; + await c.init(); + await c.update(cfg); + return c; + } + + test('resets the user and refilters against the anonymous default', + () async { + final gated = _surveyJson( + 'gated', + segment: {'id': 'seg_a', 'hasFilters': true}, + ); + final config = await seedConfig( + TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + workspace: TWorkspaceState( + expiresAt: DateTime(2100), + data: TWorkspaceData(surveys: [_surveyJson('plain'), gated]), + ), + user: const TUserState( + expiresAt: null, + data: TUserData(userId: 'u1', segments: ['seg_a']), + ), + filteredSurveys: [gated], + status: TStatus.success, + ), + ); + + await fb_setup.tearDown(config: config); + + final updated = config.get(); + expect(updated.user.data.userId, isNull); + expect( + updated.filteredSurveys.map((e) => (e as Map)['id']).toList(), + ['plain'], + reason: 'segment-filtered surveys drop for the anonymous user', + ); + }); + + test('an error-only config (no workspace) empties filteredSurveys', + () async { + final config = await seedConfig( + TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + filteredSurveys: [_surveyJson('stale')], + status: const TStatus(value: 'error'), + ), + ); + + await fb_setup.tearDown(config: config); + + expect(config.get().filteredSurveys, isEmpty); + expect(config.get().user.data.userId, isNull); + }); + + test('no loaded config → no-op', () async { + final config = FormbricksConfig.instance; + await fb_setup.tearDown(config: config); + expect(config.getOrNull(), isNull); + }); + }); } diff --git a/packages/formbricks_flutter/test/common/utils_test.dart b/packages/formbricks_flutter/test/common/utils_test.dart index 25122ec..c1390ac 100644 --- a/packages/formbricks_flutter/test/common/utils_test.dart +++ b/packages/formbricks_flutter/test/common/utils_test.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks_flutter/src/common/utils.dart'; import 'package:formbricks_flutter/src/types/survey.dart'; @@ -12,6 +14,22 @@ TSurvey _survey( if (styling != null) 'styling': styling, }); +/// A deterministic RNG: every [nextDouble] returns [value]. +class _FixedRandom implements Random { + _FixedRandom(this.value); + + final double value; + + @override + double nextDouble() => value; + + @override + int nextInt(int max) => 0; + + @override + bool nextBool() => false; +} + void main() { final langs = [ { @@ -152,4 +170,51 @@ void main() { expect(getStyling(const {}, _survey(const [])), {}); }); }); + + group('shouldDisplayBasedOnPercentage', () { + test('a 0.0 roll shows for any positive percentage', () { + expect( + shouldDisplayBasedOnPercentage(0.01, random: _FixedRandom(0)), + isTrue, + ); + expect( + shouldDisplayBasedOnPercentage(100, random: _FixedRandom(0)), + isTrue, + ); + }); + + test('a 0.999 roll shows only for percentages above 99.9', () { + expect( + shouldDisplayBasedOnPercentage(99, random: _FixedRandom(0.999)), + isFalse, + ); + expect( + shouldDisplayBasedOnPercentage(99.95, random: _FixedRandom(0.999)), + isTrue, + ); + }); + + test('a roll equal to the percentage does not show (strict <)', () { + // 0.5 and 0.25 are binary-exact, so the comparison has no FP noise. + expect( + shouldDisplayBasedOnPercentage(50, random: _FixedRandom(0.5)), + isFalse, + ); + expect( + shouldDisplayBasedOnPercentage(50, random: _FixedRandom(0.25)), + isTrue, + ); + }); + + test('a 0 percentage never shows', () { + expect( + shouldDisplayBasedOnPercentage(0, random: _FixedRandom(0)), + isFalse, + ); + }); + + test('falls back to the shared RNG when none is injected', () { + expect(shouldDisplayBasedOnPercentage(50), isA()); + }); + }); } diff --git a/packages/formbricks_flutter/test/survey/action_test.dart b/packages/formbricks_flutter/test/survey/action_test.dart index 9ba9c14..2afacc8 100644 --- a/packages/formbricks_flutter/test/survey/action_test.dart +++ b/packages/formbricks_flutter/test/survey/action_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks_flutter/src/common/config.dart'; @@ -10,7 +11,12 @@ import 'package:formbricks_flutter/src/types/errors.dart'; import 'package:formbricks_flutter/src/types/survey.dart'; import 'package:shared_preferences/shared_preferences.dart'; -Map _surveyJson(String id, List actionNames) => { +Map _surveyJson( + String id, + List actionNames, { + num? displayPercentage, +}) => + { 'id': id, 'triggers': [ for (final name in actionNames) @@ -19,10 +25,13 @@ Map _surveyJson(String id, List actionNames) => { }, ], 'languages': [], + if (displayPercentage != null) 'displayPercentage': displayPercentage, }; +/// Builds a persisted config. [filteredSurveys] defaults to [surveys]. String _configJson({ List> surveys = const [], + List>? filteredSurveys, List> actionClasses = const [], String appUrl = 'https://app.formbricks.com', }) => @@ -38,9 +47,26 @@ String _configJson({ }, }, 'user': {'expiresAt': null, 'data': {}}, + 'filteredSurveys': filteredSurveys ?? surveys, 'status': {'value': 'success', 'expiresAt': null}, }); +/// A deterministic RNG: every [nextDouble] returns [value]. +class _FixedRandom implements Random { + _FixedRandom(this.value); + + final double value; + + @override + double nextDouble() => value; + + @override + int nextInt(int max) => 0; + + @override + bool nextBool() => false; +} + Future _seed(String json) async { SharedPreferences.setMockInitialValues({FormbricksConfig.storageKey: json}); FormbricksConfig.resetInstance(); @@ -219,28 +245,74 @@ void main() { test('a malformed survey entry is skipped; valid ones still match', () async { - final json = jsonEncode({ - 'workspaceId': 'wsp_1', - 'appUrl': 'https://app.formbricks.com', - 'workspace': { - 'expiresAt': '2100-01-01T00:00:00.000', - 'data': { - 'surveys': [ - {'noId': true, 'triggers': []}, - _surveyJson('good', ['Target']), - ], - 'actionClasses': [], - 'settings': {}, - }, - }, - 'user': {'expiresAt': null, 'data': {}}, - 'status': {'value': 'success', 'expiresAt': null}, - }); - final config = await _seed(json); + final config = await _seed( + _configJson( + filteredSurveys: [ + {'noId': true, 'triggers': []}, + _surveyJson('good', ['Target']), + ], + ), + ); final result = await trackAction('Target', config: config); expect(result.isOk, isTrue); expect(SurveyStore.instance.survey?.id, 'good'); }); + + test('reads filteredSurveys — a raw workspace survey is not triggered', + () async { + final config = await _seed( + _configJson( + surveys: [ + _surveyJson('ineligible', ['Target']), + ], + filteredSurveys: const [], + ), + ); + final result = await trackAction('Target', config: config); + expect(result.isOk, isTrue); + expect(SurveyStore.instance.survey, isNull); + }); + + test('a filtered survey triggers even when absent from workspace surveys', + () async { + final config = await _seed( + _configJson( + surveys: const [], + filteredSurveys: [ + _surveyJson('eligible', ['Target']), + ], + ), + ); + final result = await trackAction('Target', config: config); + expect(result.isOk, isTrue); + expect(SurveyStore.instance.survey?.id, 'eligible'); + }); + + test('threads the injected RNG into the percentage gate', () async { + final config = await _seed( + _configJson( + filteredSurveys: [ + _surveyJson('s1', ['Target'], displayPercentage: 50), + ], + ), + ); + + var result = await trackAction( + 'Target', + config: config, + random: _FixedRandom(0.75), + ); + expect(result.isOk, isTrue); + expect(SurveyStore.instance.survey, isNull, reason: '75 ≥ 50 → skipped'); + + result = await trackAction( + 'Target', + config: config, + random: _FixedRandom(0.25), + ); + expect(result.isOk, isTrue); + expect(SurveyStore.instance.survey?.id, 's1', reason: '25 < 50 → shown'); + }); }); group('triggerSurvey', () { @@ -248,5 +320,38 @@ void main() { triggerSurvey(TSurvey.fromJson({'id': 'x'})); expect(SurveyStore.instance.survey?.id, 'x'); }); + + test('a roll below the displayPercentage sets the survey', () { + triggerSurvey( + TSurvey.fromJson({'id': 'x', 'displayPercentage': 50}), + random: _FixedRandom(0.25), + ); + expect(SurveyStore.instance.survey?.id, 'x'); + }); + + test('a roll at/above the displayPercentage skips the survey', () { + triggerSurvey( + TSurvey.fromJson({'id': 'x', 'displayPercentage': 50}), + random: _FixedRandom(0.5), + ); + expect(SurveyStore.instance.survey, isNull); + }); + + test('a displayPercentage of 0 bypasses the gate entirely', () { + triggerSurvey( + TSurvey.fromJson({'id': 'x', 'displayPercentage': 0}), + // A 0-roll would fail `0 < 0` if the gate ran. + random: _FixedRandom(0), + ); + expect(SurveyStore.instance.survey?.id, 'x'); + }); + + test('a null displayPercentage always sets the survey', () { + triggerSurvey( + TSurvey.fromJson({'id': 'x'}), + random: _FixedRandom(0.999), + ); + expect(SurveyStore.instance.survey?.id, 'x'); + }); }); } diff --git a/packages/formbricks_flutter/test/types/survey_test.dart b/packages/formbricks_flutter/test/types/survey_test.dart index 27c3fcc..c9ba1e3 100644 --- a/packages/formbricks_flutter/test/types/survey_test.dart +++ b/packages/formbricks_flutter/test/types/survey_test.dart @@ -37,6 +37,11 @@ Map _fullJson() => { 'clickOutsideClose': false, 'overlay': 'dark', }, + 'displayOption': 'displaySome', + 'displayLimit': 2, + 'recontactDays': 7, + 'displayPercentage': 50.5, + 'segment': {'id': 'seg_1', 'hasFilters': true}, }; void main() { @@ -58,6 +63,12 @@ void main() { expect(survey.projectOverwrites?.clickOutsideClose, false); expect(survey.projectOverwrites?.overlay, 'dark'); expect(survey.isMultiLanguage, isTrue); + expect(survey.displayOption, 'displaySome'); + expect(survey.displayLimit, 2); + expect(survey.recontactDays, 7); + expect(survey.displayPercentage, 50.5); + expect(survey.segment?.id, 'seg_1'); + expect(survey.segment?.hasFilters, isTrue); }); test('toJson returns the raw map verbatim (lossless)', () { @@ -75,6 +86,22 @@ void main() { expect(survey.styling, isNull); expect(survey.projectOverwrites, isNull); expect(survey.isMultiLanguage, isFalse); + expect(survey.displayOption, isNull); + expect(survey.displayLimit, isNull); + expect(survey.recontactDays, isNull); + expect(survey.displayPercentage, isNull); + expect(survey.segment, isNull); + }); + + test('integral displayLimit/recontactDays survive a double encoding', () { + // JSON decoders may produce doubles for whole numbers. + final survey = TSurvey.fromJson({ + 'id': 's', + 'displayLimit': 2.0, + 'recontactDays': 7.0, + }); + expect(survey.displayLimit, 2); + expect(survey.recontactDays, 7); }); test('a single language is not multi-language', () { @@ -92,4 +119,37 @@ void main() { expect(survey.languages.single.language.alias, isNull); }); }); + + group('TSurveySegment', () { + TSurveySegment? parse(Map segment) => + TSurvey.fromJson({'id': 's', 'segment': segment}).segment; + + test('boolean hasFilters is taken verbatim', () { + expect(parse({'id': 'a', 'hasFilters': true})?.hasFilters, isTrue); + expect(parse({'id': 'a', 'hasFilters': false})?.hasFilters, isFalse); + }); + + test('legacy non-empty filters array → hasFilters', () { + final segment = parse({ + 'id': 'a', + 'filters': [ + {'connector': null}, + ], + }); + expect(segment?.id, 'a'); + expect(segment?.hasFilters, isTrue); + }); + + test('legacy empty filters array → no filters', () { + expect(parse({'id': 'a', 'filters': []})?.hasFilters, isFalse); + }); + + test('non-list filters value → no filters', () { + expect(parse({'id': 'a', 'filters': 'bogus'})?.hasFilters, isFalse); + }); + + test('a missing id stays null', () { + expect(parse({'hasFilters': true})?.id, isNull); + }); + }); } diff --git a/packages/formbricks_flutter/test/user/update_queue_test.dart b/packages/formbricks_flutter/test/user/update_queue_test.dart index 8936694..e6bf1d9 100644 --- a/packages/formbricks_flutter/test/user/update_queue_test.dart +++ b/packages/formbricks_flutter/test/user/update_queue_test.dart @@ -18,18 +18,23 @@ String _configJson({ String? userId, String? language, bool omitWorkspaceId = false, + bool omitWorkspace = false, + List> surveys = const [], + List> filteredSurveys = const [], }) => jsonEncode({ 'workspaceId': omitWorkspaceId ? null : _workspaceId, 'appUrl': _appUrl, - 'workspace': { - 'expiresAt': '2100-01-01T00:00:00.000', - 'data': { - 'surveys': [], - 'actionClasses': [], - 'settings': {}, - }, - }, + 'workspace': omitWorkspace + ? null + : { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': surveys, + 'actionClasses': [], + 'settings': {}, + }, + }, 'user': { 'expiresAt': null, 'data': { @@ -37,6 +42,7 @@ String _configJson({ if (language != null) 'language': language, }, }, + 'filteredSurveys': filteredSurveys, 'status': {'value': 'success', 'expiresAt': null}, }); @@ -44,12 +50,18 @@ Future _seed({ String? userId, String? language, bool omitWorkspaceId = false, + bool omitWorkspace = false, + List> surveys = const [], + List> filteredSurveys = const [], }) async { SharedPreferences.setMockInitialValues({ FormbricksConfig.storageKey: _configJson( userId: userId, language: language, omitWorkspaceId: omitWorkspaceId, + omitWorkspace: omitWorkspace, + surveys: surveys, + filteredSurveys: filteredSurveys, ), }); FormbricksConfig.resetInstance(); @@ -58,15 +70,32 @@ Future _seed({ return config; } +Map _surveyJson( + String id, { + Map? segment, +}) => + { + 'id': id, + 'displayOption': 'respondMultiple', + 'triggers': [], + 'languages': [], + if (segment != null) 'segment': segment, + }; + /// A user-state response body that echoes [userId]. -String _userResponse(String userId, {List? errors}) => jsonEncode({ +String _userResponse( + String userId, { + List? errors, + List segments = const [], +}) => + jsonEncode({ 'data': { 'state': { 'expiresAt': null, 'data': { 'userId': userId, 'contactId': 'c1', - 'segments': [], + 'segments': segments, 'displays': [], 'responses': [], 'lastDisplayAt': null, @@ -340,6 +369,102 @@ void main() { }); }); + test('successful flush refilters surveys against the synced user', () async { + final config = await _seed( + userId: 'u1', + surveys: [ + _surveyJson('gated', segment: {'id': 'seg_a', 'hasFilters': true}), + _surveyJson('plain'), + ], + ); + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient( + (req) async => + http.Response(_userResponse('u1', segments: ['seg_a']), 200), + ), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + expect( + config.get().filteredSurveys.map((e) => (e as Map)['id']).toList(), + ['gated'], + reason: 'only the segment-matched survey survives for the synced user', + ); + }); + }); + + test('a synced user with no matched segments empties filteredSurveys', + () async { + final config = await _seed( + userId: 'u1', + surveys: [_surveyJson('plain')], + // Stale eligible set from before the sync. + filteredSurveys: [_surveyJson('plain')], + ); + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient( + (req) async => http.Response(_userResponse('u1'), 200), + ), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + expect(config.get().filteredSurveys, isEmpty); + }); + }); + + test('a flush against a workspace-less config still persists the user', + () async { + final config = await _seed( + userId: 'u1', + omitWorkspace: true, + filteredSurveys: [_surveyJson('stale')], + ); + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient( + (req) async => http.Response(_userResponse('u1'), 200), + ), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + expect(config.get().user.data.contactId, 'c1', reason: 'state synced'); + expect( + config.get().filteredSurveys, + isEmpty, + reason: 'no workspace → nothing can be eligible', + ); + }); + }); + test('clear cancels the pending timer — no flush afterwards', () async { final config = await _seed(userId: 'u1'); var calls = 0; diff --git a/packages/formbricks_flutter/test/user/user_test.dart b/packages/formbricks_flutter/test/user/user_test.dart index ab87a5b..d10e21b 100644 --- a/packages/formbricks_flutter/test/user/user_test.dart +++ b/packages/formbricks_flutter/test/user/user_test.dart @@ -26,13 +26,18 @@ ApiClient _noopApi() => ApiClient( ), ); -String _configJson({String? userId}) => jsonEncode({ +String _configJson({ + String? userId, + List> surveys = const [], + List> filteredSurveys = const [], +}) => + jsonEncode({ 'workspaceId': 'wsp_1', 'appUrl': 'https://app.formbricks.com', 'workspace': { 'expiresAt': '2100-01-01T00:00:00.000', 'data': { - 'surveys': [], + 'surveys': surveys, 'actionClasses': [], 'settings': {}, }, @@ -44,12 +49,21 @@ String _configJson({String? userId}) => jsonEncode({ 'segments': ['seg_1'], }, }, + 'filteredSurveys': filteredSurveys, 'status': {'value': 'success', 'expiresAt': null}, }); -Future _seed({String? userId}) async { +Future _seed({ + String? userId, + List> surveys = const [], + List> filteredSurveys = const [], +}) async { SharedPreferences.setMockInitialValues({ - FormbricksConfig.storageKey: _configJson(userId: userId), + FormbricksConfig.storageKey: _configJson( + userId: userId, + surveys: surveys, + filteredSurveys: filteredSurveys, + ), }); FormbricksConfig.resetInstance(); final config = FormbricksConfig.instance; @@ -127,5 +141,35 @@ void main() { final decoded = jsonDecode(raw!) as Map; expect((decoded['user'] as Map)['data']['userId'], isNull); }); + + test('refilters surveys against the anonymous user', () async { + final gated = { + 'id': 'gated', + 'displayOption': 'respondMultiple', + 'triggers': [], + 'languages': [], + 'segment': {'id': 'seg_1', 'hasFilters': true}, + }; + final plain = { + 'id': 'plain', + 'displayOption': 'respondMultiple', + 'triggers': [], + 'languages': [], + }; + final config = await _seed( + userId: 'u1', + surveys: [gated, plain], + filteredSurveys: [gated], + ); + + final result = await logout(config: config); + + expect(result.isOk, isTrue); + expect( + config.get().filteredSurveys.map((e) => (e as Map)['id']).toList(), + ['plain'], + reason: 'segment-filtered surveys drop after logout', + ); + }); }); } diff --git a/packages/formbricks_flutter/test/widgets/survey_webview_test.dart b/packages/formbricks_flutter/test/widgets/survey_webview_test.dart index 6def918..9bef0c3 100644 --- a/packages/formbricks_flutter/test/widgets/survey_webview_test.dart +++ b/packages/formbricks_flutter/test/widgets/survey_webview_test.dart @@ -40,22 +40,28 @@ const _stub = Key('stub-webview'); Future _seedConfig({ String? language, Map settings = const {}, + List> surveys = const [], + List> filteredSurveys = const [], + bool omitWorkspace = false, }) async { final json = jsonEncode({ 'workspaceId': 'wsp_1', 'appUrl': 'https://app.formbricks.com', - 'workspace': { - 'expiresAt': '2100-01-01T00:00:00.000', - 'data': { - 'surveys': [], - 'actionClasses': [], - 'settings': settings, - }, - }, + 'workspace': omitWorkspace + ? null + : { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': surveys, + 'actionClasses': [], + 'settings': settings, + }, + }, 'user': { 'expiresAt': null, 'data': {if (language != null) 'language': language}, }, + 'filteredSurveys': filteredSurveys, 'status': {'value': 'success', 'expiresAt': null}, }); SharedPreferences.setMockInitialValues({FormbricksConfig.storageKey: json}); @@ -276,6 +282,126 @@ void main() { expect(storedData!['responses'], ['s1']); // persisted to disk }); + testWidgets('DisplayCreatedEvent refilters — a displayOnce survey drops out', + (tester) async { + final surveyJson = { + 'id': 's1', + 'displayOption': 'displayOnce', + 'triggers': [], + 'languages': [], + }; + await _seedConfig(surveys: [surveyJson], filteredSurveys: [surveyJson]); + final host = await _present(tester, _survey(surveyJson)); + + await tester.runAsync(() async { + host.onEvent!(const DisplayCreatedEvent()); + await _waitUntil(() async { + final data = await _storedUserData(); + return (data['displays'] as List?)?.isNotEmpty ?? false; + }); + }); + + expect( + FormbricksConfig.instance.get().filteredSurveys, + isEmpty, + reason: 'the just-displayed displayOnce survey must leave the ' + 'eligible set immediately', + ); + }); + + testWidgets( + 'ResponseCreatedEvent refilters — a displayMultiple survey drops out', + (tester) async { + final surveyJson = { + 'id': 's1', + 'displayOption': 'displayMultiple', + 'triggers': [], + 'languages': [], + }; + await _seedConfig(surveys: [surveyJson], filteredSurveys: [surveyJson]); + final host = await _present(tester, _survey(surveyJson)); + + await tester.runAsync(() async { + host.onEvent!(const ResponseCreatedEvent()); + await _waitUntil(() async { + final data = await _storedUserData(); + return (data['responses'] as List?)?.isNotEmpty ?? false; + }); + }); + + expect( + FormbricksConfig.instance.get().filteredSurveys, + isEmpty, + reason: 'the just-answered displayMultiple survey must leave the ' + 'eligible set immediately', + ); + }); + + testWidgets('DisplayCreatedEvent refilter keeps still-eligible surveys', + (tester) async { + final shown = { + 'id': 's1', + 'displayOption': 'displayOnce', + 'triggers': [], + 'languages': [], + }; + final keeper = { + 'id': 'keeper', + 'displayOption': 'respondMultiple', + 'triggers': [], + 'languages': [], + }; + await _seedConfig( + surveys: [shown, keeper], + filteredSurveys: [shown, keeper], + ); + final host = await _present(tester, _survey(shown)); + + await tester.runAsync(() async { + host.onEvent!(const DisplayCreatedEvent()); + await _waitUntil(() async { + final data = await _storedUserData(); + return (data['displays'] as List?)?.isNotEmpty ?? false; + }); + }); + + expect( + FormbricksConfig.instance + .get() + .filteredSurveys + .map((e) => (e as Map)['id']) + .toList(), + ['keeper'], + reason: 'only the just-displayed displayOnce survey drops out', + ); + }); + + testWidgets( + 'DisplayCreatedEvent without a workspace empties filteredSurveys ' + 'without throwing', (tester) async { + final surveyJson = { + 'id': 's1', + 'triggers': [], + 'languages': [], + }; + await _seedConfig( + omitWorkspace: true, + filteredSurveys: [surveyJson], + ); + final host = await _present(tester, _survey(surveyJson)); + + await tester.runAsync(() async { + host.onEvent!(const DisplayCreatedEvent()); + await _waitUntil(() async { + final data = await _storedUserData(); + return (data['displays'] as List?)?.isNotEmpty ?? false; + }); + }); + + expect(FormbricksConfig.instance.get().filteredSurveys, isEmpty); + expect(tester.takeException(), isNull); + }); + testWidgets('OpenExternalUrlEvent launches via the injected launcher', (tester) async { await _seedConfig(); diff --git a/packages/formbricks_flutter/test/widgets/webview_event_test.dart b/packages/formbricks_flutter/test/widgets/webview_event_test.dart index ffca9c5..8ae6156 100644 --- a/packages/formbricks_flutter/test/widgets/webview_event_test.dart +++ b/packages/formbricks_flutter/test/widgets/webview_event_test.dart @@ -37,7 +37,7 @@ void main() { expect((event as ConsoleEvent).log, contains('hello')); }); - test('a multi-flag payload fires multiple events in RN order', () { + test('a multi-flag payload fires multiple events in declaration order', () { final events = parseWebViewEvents('{"onResponseCreated":true,"onClose":true}'); expect(events.length, 2); diff --git a/pubspec.lock b/pubspec.lock index 32963be..50cf20f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -158,6 +158,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: transitive description: @@ -176,6 +181,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -208,6 +218,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" io: dependency: transitive description: @@ -509,6 +524,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -621,6 +644,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webview_flutter: dependency: transitive description: