From 94791f730c70ff7db5a6245c4ca7eba9eafbbd21 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 9 Jun 2026 17:12:32 +0530 Subject: [PATCH 1/6] feat: adds user identification functions --- apps/playground/lib/main.dart | 114 +++++- .../lib/src/common/setup.dart | 20 ++ .../lib/src/user/attribute.dart | 44 +++ .../lib/src/user/update_queue.dart | 242 +++++++++++++ .../formbricks_flutter/lib/src/user/user.dart | 58 +++ .../lib/src/widgets/formbricks_widget.dart | 66 ++++ .../test/user/attribute_test.dart | 74 ++++ .../test/user/update_queue_test.dart | 337 ++++++++++++++++++ .../test/user/user_api_test.dart | 131 +++++++ .../test/user/user_test.dart | 109 ++++++ 10 files changed, 1183 insertions(+), 12 deletions(-) create mode 100644 packages/formbricks_flutter/lib/src/user/attribute.dart create mode 100644 packages/formbricks_flutter/lib/src/user/update_queue.dart create mode 100644 packages/formbricks_flutter/lib/src/user/user.dart create mode 100644 packages/formbricks_flutter/test/user/attribute_test.dart create mode 100644 packages/formbricks_flutter/test/user/update_queue_test.dart create mode 100644 packages/formbricks_flutter/test/user/user_api_test.dart create mode 100644 packages/formbricks_flutter/test/user/user_test.dart diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 7c3f765..62d36ba 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -143,25 +143,99 @@ class _PlaygroundHomeState extends State { ); } - void _stub(BuildContext context, String action) { - ScaffoldMessenger.of(context) + /// Runs an identity command and reports the result in a snackbar. + /// + /// The user-update queue debounces the backend call ~500 ms after the last + /// identity/attribute change, so an `ok` here means the command was accepted, + /// not that the network round-trip has finished. + Future _runAction( + BuildContext context, + String action, + Future> Function() op, + ) async { + final messenger = ScaffoldMessenger.of(context); + String message; + try { + final result = await op(); + message = switch (result) { + Ok() => '$action -> ok', + Err(:final error) => '$action -> ${error.code.wire}: ${error.message}', + }; + } on FormbricksError catch (e) { + message = '$action -> ${e.code.wire}: ${e.message}'; + } + if (!mounted) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + /// Reads the persisted config and dumps it to the console. + Future _logStorage(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final raw = await Formbricks.debugStoredConfig(); + debugPrint('Formbricks local storage: ${raw ?? ''}'); + if (!mounted) return; + messenger ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text('$action - not wired to the SDK yet'), - duration: const Duration(seconds: 1), + content: Text( + raw == null + ? 'local storage is empty' + : 'local storage logged to console (${raw.length} chars)', + ), ), ); } + /// Clears the persisted config + in-memory copy. + Future _clearStorage(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + await Formbricks.debugClearStoredConfig(); + if (!mounted) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar(content: Text('local storage cleared')), + ); + } + @override Widget build(BuildContext context) { - final stubActions = <({String label, String action})>[ - (label: 'Set userId', action: "setUserId('random-user-id')"), - (label: 'Set User Attributes (multiple)', action: 'setAttributes({...})'), - (label: 'Set User Attribute (single)', action: "setAttribute('k', 'v')"), - (label: 'Set Language (de)', action: "setLanguage('de')"), - (label: 'Logout', action: 'logout()'), + final identityActions = + <({String label, String action, Future> Function() op})>[ + ( + label: 'Set userId', + action: "setUserId('playground-user')", + op: () => Formbricks.setUserId('playground-user'), + ), + ( + label: 'Set User Attributes (multiple)', + action: 'setAttributes({plan, mrr, signup_date})', + op: () => Formbricks.setAttributes({ + 'plan': 'pro', + 'mrr': 99, + 'signup_date': DateTime.now(), + }), + ), + ( + label: 'Set User Attribute (single)', + action: "setAttribute('source', 'playground')", + op: () => Formbricks.setAttribute('source', 'playground'), + ), + ( + label: 'Set Language (de)', + action: "setLanguage('de')", + op: () => Formbricks.setLanguage('de'), + ), + ( + label: 'Logout', + action: 'logout()', + op: Formbricks.logout, + ), ]; final textTheme = Theme.of(context).textTheme; @@ -250,14 +324,30 @@ class _PlaygroundHomeState extends State { ], const Divider(height: 40), - for (final a in stubActions) ...[ + for (final a in identityActions) ...[ FilledButton.tonal( - onPressed: () => _stub(context, a.action), + onPressed: _connected + ? () => _runAction(context, a.action, a.op) + : null, child: Text(a.label), ), const SizedBox(height: 12), ], + const Divider(height: 40), + Text('Local storage', style: textTheme.titleSmall), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => _logStorage(context), + child: const Text('Log Local Storage'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => _clearStorage(context), + child: const Text('Clear Local Storage'), + ), + const SizedBox(height: 12), + // Mounted once connected so triggered surveys can render // against the connected workspace. if (_connected) diff --git a/packages/formbricks_flutter/lib/src/common/setup.dart b/packages/formbricks_flutter/lib/src/common/setup.dart index 70d5b4a..52c251d 100644 --- a/packages/formbricks_flutter/lib/src/common/setup.dart +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -243,6 +243,26 @@ Future _handleErrorOnFirstSetup( ); } +/// Resets user state back to anonymous and persists it. +/// +/// 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. +Future tearDown({FormbricksConfig? config}) async { + final cfg = config ?? FormbricksConfig.instance; + Logger.debug('Setting user state to default'); + + final current = cfg.getOrNull(); + if (current == null) return; + + 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. + ); +} + NetworkError _toNetworkError(ApiErrorResponse error) => NetworkError( message: error.message, status: error.status, diff --git a/packages/formbricks_flutter/lib/src/user/attribute.dart b/packages/formbricks_flutter/lib/src/user/attribute.dart new file mode 100644 index 0000000..748af5b --- /dev/null +++ b/packages/formbricks_flutter/lib/src/user/attribute.dart @@ -0,0 +1,44 @@ +/// 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 +/// 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; + +import 'dart:async'; + +import '../common/result.dart'; +import '../types/errors.dart'; +import 'update_queue.dart'; + +/// Sets [attributes] on the current user/contact. +/// +/// `DateTime` values become UTC ISO-8601 strings; `num` and `String` values are +/// passed through unchanged. Queues the change through the debounced +/// [UpdateQueue] and returns `Ok` immediately (the network call, and any +/// no-userId error, surface from the queue's flush). +Future> setAttributes( + Map attributes, { + UpdateQueue? queue, +}) async { + final normalized = {}; + attributes.forEach((key, value) { + normalized[key] = value is DateTime ? value.toUtc().toIso8601String() : value; + }); + + final q = queue ?? UpdateQueue.instance; + q.updateAttributes(normalized); + unawaited(q.processUpdates()); + return const Result.ok(null); +} + +/// 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. +Future> setLanguage( + String language, { + UpdateQueue? queue, +}) => + setAttributes({'language': language}, queue: queue); diff --git a/packages/formbricks_flutter/lib/src/user/update_queue.dart b/packages/formbricks_flutter/lib/src/user/update_queue.dart new file mode 100644 index 0000000..5a0a01b --- /dev/null +++ b/packages/formbricks_flutter/lib/src/user/update_queue.dart @@ -0,0 +1,242 @@ +/// 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, +/// 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 +/// debounce flow is fire-and-forget at the call site (`unawaited(processUpdates())`). +/// A failed flush drops the whole batch (no retry, no partial update). +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../common/api_client.dart'; +import '../common/config.dart'; +import '../common/logger.dart'; +import '../common/result.dart'; +import '../types/config.dart'; +import '../types/errors.dart'; + +/// The pending, un-flushed identity/attribute accumulator. +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). + final String userId; + + /// The attributes to apply (numbers preserved as numbers; dates already ISO). + final Map? attributes; +} + +/// A 500 ms debounce that coalesces user updates into one backend call. +class UpdateQueue { + UpdateQueue._(); + + static UpdateQueue? _instance; + + /// The process-wide queue singleton. + static UpdateQueue get instance => _instance ??= UpdateQueue._(); + + /// The debounce window: a flush fires this long after the last call. + static const Duration debounceDelay = Duration(milliseconds: 500); + + /// Config override for tests. Defaults to [FormbricksConfig.instance]. + @visibleForTesting + FormbricksConfig? configOverride; + + /// API-client override for tests. When set, [_sendUpdates] uses it instead of + /// building one from config (and does not close it). + @visibleForTesting + ApiClient? apiClientOverride; + + FormbricksConfig get _config => configOverride ?? FormbricksConfig.instance; + + _PendingUpdates? _updates; + Timer? _debounce; + Completer? _flushCompleter; + + /// Whether nothing is queued. + bool get isEmpty => _updates == null; + + /// The queued userId, or null when nothing is queued. Test-only. + @visibleForTesting + String? get pendingUserId => _updates?.userId; + + /// The queued attributes, or null when nothing is queued. Test-only. + @visibleForTesting + Map? get pendingAttributes => _updates?.attributes; + + /// Merges [userId] into the pending updates (creating them if empty). + void updateUserId(String userId) { + final current = _updates; + _updates = _PendingUpdates( + userId: userId, + attributes: current?.attributes ?? {}, + ); + } + + /// 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. + void updateAttributes(Map attributes) { + final pendingUserId = _updates?.userId; + final userId = (pendingUserId != null && pendingUserId.isNotEmpty) + ? pendingUserId + : (_config.getOrNull()?.user.data.userId ?? ''); + + final current = _updates; + _updates = _PendingUpdates( + userId: userId, + attributes: {...?current?.attributes, ...attributes}, + ); + } + + /// Schedules a debounced [_flush]. + /// + /// Each call cancels the previous timer so the flush fires 500 ms after the + /// *last* call. Calls within one window share a single completer, completed + /// (or completed-with-error) when that window's flush runs. Fire-and-forget + /// at the call site; the returned future exists for tests. + Future processUpdates() { + if (_updates == null) return Future.value(); + + _debounce?.cancel(); + final completer = _flushCompleter ??= Completer(); + + _debounce = Timer(debounceDelay, () async { + final c = _flushCompleter; + _flushCompleter = null; + _debounce = null; + try { + await _flush(); + if (c != null && !c.isCompleted) c.complete(); + } catch (error, stackTrace) { + if (c != null && !c.isCompleted) c.completeError(error, stackTrace); + } + }); + + return completer.future; + } + + /// Cancels any pending flush and clears the buffer. Call on disposal/hot + /// reload so a dangling [Timer] can't fire after the queue is gone. + void dispose() { + _debounce?.cancel(); + _debounce = null; + final c = _flushCompleter; + _flushCompleter = null; + if (c != null && !c.isCompleted) c.complete(); + _updates = null; + } + + /// The debounced handler (port of `update-queue.ts:154–195`). + Future _flush() async { + final pending = _updates; + if (pending == null) return; + + final cfg = _config.get(); + + // Resolve the effective userId: pending (non-empty) first, then config. + final pendingUserId = pending.userId; + final effectiveUserId = (pendingUserId.isNotEmpty) + ? pendingUserId + : cfg.user.data.userId; + + var attributes = {...?pending.attributes}; + + // Language without a userId updates local config only — never the backend. + if (effectiveUserId == null || effectiveUserId.isEmpty) { + attributes = await _handleLanguageWithoutUserId(attributes); + } + + // Attributes require a userId. If any remain without one, error and clear. + if (attributes.isNotEmpty && + (effectiveUserId == null || effectiveUserId.isEmpty)) { + const message = + "Formbricks can't set attributes without a userId! Please set a " + 'userId first with the setUserId function'; + Logger.error(message); + _updates = null; + throw MissingFieldError('userId', message: message); + } + + await _sendUpdates(effectiveUserId, attributes); + _updates = null; + } + + /// Writes a queued `language` straight into local config (no API call) and + /// strips it from [attributes] (port of `update-queue.ts:68–96`). + Future> _handleLanguageWithoutUserId( + Map attributes, + ) async { + final language = attributes['language']; + if (language is! String) return attributes; + + final cfg = _config.get(); + await _config.update( + cfg.copyWith( + user: cfg.user.copyWith( + data: cfg.user.data.copyWith(language: language), + ), + ), + ); + Logger.debug('Updated language successfully'); + + 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. + Future _sendUpdates( + String? userId, + Map attributes, + ) async { + if (userId == null || userId.isEmpty) return; + + final cfg = _config.get(); + final injected = apiClientOverride; + final api = injected ?? + ApiClient(appUrl: cfg.appUrl!, workspaceId: cfg.workspaceId!); + + Result result; + try { + result = await api.createOrUpdateUser( + userId: userId, + attributes: attributes, + ); + } finally { + if (injected == null) api.close(); + } + + switch (result) { + case Err(:final error): + // No retry — the buffer is already cleared by the flush, so nothing + // re-sends until the next identity/attribute change. + Logger.error('Failed to send updates: ${error.message}'); + case Ok(:final value): + // errors => always-visible; messages => debug-only. + value.errors?.forEach(Logger.error); + 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. + + if (!hasWarnings) Logger.debug('Updates sent successfully'); + } + } + + /// Drops the singleton so each test starts clean. Test-only. + @visibleForTesting + static void resetInstance() { + _instance?.dispose(); + _instance = null; + } +} diff --git a/packages/formbricks_flutter/lib/src/user/user.dart b/packages/formbricks_flutter/lib/src/user/user.dart new file mode 100644 index 0000000..0a9c54b --- /dev/null +++ b/packages/formbricks_flutter/lib/src/user/user.dart @@ -0,0 +1,58 @@ +/// 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 +/// switching to a different user. +library; + +import 'dart:async'; + +import '../common/config.dart'; +import '../common/logger.dart'; +import '../common/result.dart'; +import '../common/setup.dart'; +import '../types/errors.dart'; +import 'update_queue.dart'; + +/// Identifies the current contact as [userId]. +/// +/// - Same value already set → idempotent `Ok` no-op. +/// - A *different* userId already set → [tearDown] the previous user state +/// first, then queue the new id. +/// - Anonymous → queue the id directly (no teardown). +/// +/// Returns immediately with `Ok`; the backend `createOrUpdateUser` fires from +/// the debounced [UpdateQueue]. +Future> setUserId( + String userId, { + FormbricksConfig? config, + UpdateQueue? queue, +}) async { + final cfg = config ?? FormbricksConfig.instance; + final q = queue ?? UpdateQueue.instance; + + final currentUserId = cfg.get().user.data.userId; + + if (currentUserId == userId) { + Logger.debug('UserId is already set to the same value, skipping'); + return const Result.ok(null); + } + + if (currentUserId != null) { + Logger.debug( + 'Different userId is being set, cleaning up previous user state', + ); + await tearDown(config: cfg); + } + + q.updateUserId(userId); + unawaited(q.processUpdates()); + return const Result.ok(null); +} + +/// Logs the current user out, resetting user state to anonymous. +Future> logout({FormbricksConfig? config}) async { + Logger.debug('Logging out and cleaning user state'); + await tearDown(config: config ?? FormbricksConfig.instance); + return const Result.ok(null); +} diff --git a/packages/formbricks_flutter/lib/src/widgets/formbricks_widget.dart b/packages/formbricks_flutter/lib/src/widgets/formbricks_widget.dart index 47ebc05..47bd81e 100644 --- a/packages/formbricks_flutter/lib/src/widgets/formbricks_widget.dart +++ b/packages/formbricks_flutter/lib/src/widgets/formbricks_widget.dart @@ -16,6 +16,8 @@ import '../survey/action.dart' as action; import '../survey/survey_store.dart'; import '../types/errors.dart'; import '../types/survey.dart'; +import '../user/attribute.dart' as attribute; +import '../user/user.dart' as user; import 'default_webview_host.dart'; import 'survey_webview.dart'; import 'webview_navigation.dart'; @@ -103,6 +105,64 @@ class Formbricks extends StatefulWidget { ); } + /// Identifies the current contact as [userId] (`checkSetup: true`). + /// + /// Idempotent for the already-set value; switching to a different id resets + /// the prior user state first. The backend sync runs from the debounced + /// update queue, so this resolves before the network call completes. + static Future> setUserId(String userId) { + return _queue.add>( + () => user.setUserId(userId), + checkSetup: true, + ); + } + + /// Sets a single contact attribute [key] = [value] (`checkSetup: true`). + /// + /// [value] may be a `String`, `num`, or `DateTime` (dates serialize to + /// ISO-8601). Requires a userId to have been set; otherwise the queued update + /// is dropped with an error logged. + static Future> setAttribute( + String key, + Object value, + ) { + return _queue.add>( + () => attribute.setAttributes({key: value}), + checkSetup: true, + ); + } + + /// Sets multiple contact [attributes] at once (`checkSetup: true`). + /// + /// Values may be `String`, `num`, or `DateTime`. See [setAttribute]. + static Future> setAttributes( + Map attributes, + ) { + return _queue.add>( + () => attribute.setAttributes(attributes), + checkSetup: true, + ); + } + + /// Sets the contact's preferred [language] (`checkSetup: true`). + /// + /// With no userId set, this updates only the local config (no network call). + static Future> setLanguage(String language) { + return _queue.add>( + () => attribute.setLanguage(language), + checkSetup: true, + ); + } + + /// Logs the current user out, resetting user state to anonymous + /// (`checkSetup: true`). + static Future> logout() { + return _queue.add>( + () => user.logout(), + checkSetup: true, + ); + } + /// Returns the raw JSON the SDK has persisted in `SharedPreferences` (under /// the `formbricks-flutter` key), or `null` if nothing is stored yet. static Future debugStoredConfig() async { @@ -110,6 +170,12 @@ class Formbricks extends StatefulWidget { return prefs.getString(FormbricksConfig.storageKey); } + /// Clears the persisted config (the `formbricks-flutter` key) and the + /// in-memory copy. Debug helper — the SDK stays "set up" for this process, so + /// the next workspace/user sync will repopulate storage. + static Future debugClearStoredConfig() => + FormbricksConfig.instance.reset(); + @override State createState() => _FormbricksState(); } diff --git a/packages/formbricks_flutter/test/user/attribute_test.dart b/packages/formbricks_flutter/test/user/attribute_test.dart new file mode 100644 index 0000000..b470e6a --- /dev/null +++ b/packages/formbricks_flutter/test/user/attribute_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +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/user/attribute.dart'; +import 'package:formbricks_flutter/src/user/update_queue.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +String _configJson() => jsonEncode({ + 'workspaceId': 'wsp_1', + 'appUrl': 'https://app.formbricks.com', + 'workspace': { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + 'user': { + 'expiresAt': null, + 'data': {'userId': 'u1'}, + }, + 'status': {'value': 'success', 'expiresAt': null}, + }); + +Future _seed() async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: _configJson(), + }); + FormbricksConfig.resetInstance(); + final config = FormbricksConfig.instance; + await config.init(); + return config; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FormbricksConfig config; + late UpdateQueue queue; + + setUp(() async { + Logger.resetInstance(); + UpdateQueue.resetInstance(); + config = await _seed(); + queue = UpdateQueue.instance..configOverride = config; + }); + + tearDown(UpdateQueue.resetInstance); + + test('DateTime normalized to UTC ISO-8601 before queueing', () async { + final date = DateTime.utc(2026, 6, 9, 10, 30); + await setAttributes({'signupDate': date}, queue: queue); + + expect(queue.pendingAttributes!['signupDate'], date.toIso8601String()); + expect(queue.pendingAttributes!['signupDate'], isA()); + }); + + test('num preserved as number, String preserved', () async { + await setAttributes({'mrr': 99, 'plan': 'pro'}, queue: queue); + + expect(queue.pendingAttributes!['mrr'], 99); + expect(queue.pendingAttributes!['mrr'], isA()); + expect(queue.pendingAttributes!['plan'], 'pro'); + }); + + test('setLanguage delegates to setAttributes({language})', () async { + await setLanguage('de', queue: queue); + + expect(queue.pendingAttributes!['language'], 'de'); + }); +} diff --git a/packages/formbricks_flutter/test/user/update_queue_test.dart b/packages/formbricks_flutter/test/user/update_queue_test.dart new file mode 100644 index 0000000..ed96471 --- /dev/null +++ b/packages/formbricks_flutter/test/user/update_queue_test.dart @@ -0,0 +1,337 @@ +import 'dart:convert'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/api_client.dart'; +import 'package:formbricks_flutter/src/common/config.dart'; +import 'package:formbricks_flutter/src/common/logger.dart'; +import 'package:formbricks_flutter/src/types/errors.dart'; +import 'package:formbricks_flutter/src/user/update_queue.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _appUrl = 'https://app.formbricks.com'; +const _workspaceId = 'wsp_1'; + +String _configJson({String? userId, String? language}) => jsonEncode({ + 'workspaceId': _workspaceId, + 'appUrl': _appUrl, + 'workspace': { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + 'user': { + 'expiresAt': null, + 'data': { + 'userId': userId, + if (language != null) 'language': language, + }, + }, + 'status': {'value': 'success', 'expiresAt': null}, + }); + +Future _seed({String? userId, String? language}) async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: _configJson(userId: userId, language: language), + }); + FormbricksConfig.resetInstance(); + final config = FormbricksConfig.instance; + await config.init(); + return config; +} + +/// A user-state response body that echoes [userId]. +String _userResponse(String userId, {List? errors}) => jsonEncode({ + 'data': { + 'state': { + 'expiresAt': null, + 'data': { + 'userId': userId, + 'contactId': 'c1', + 'segments': [], + 'displays': [], + 'responses': [], + 'lastDisplayAt': null, + }, + }, + if (errors != null) 'errors': errors, + }, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + Logger.resetInstance(); + UpdateQueue.resetInstance(); + }); + + tearDown(UpdateQueue.resetInstance); + + test('coalesces rapid updates into one createOrUpdateUser call', () async { + final config = await _seed(); + final requests = []; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + requests.add(req); + return http.Response(_userResponse('u1'), 200); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateUserId('u1') + ..processUpdates(); + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + queue + ..updateAttributes({'mrr': 99}) + ..processUpdates(); + + async.elapse(const Duration(milliseconds: 500)); + + expect(requests, hasLength(1)); + final body = jsonDecode(requests.single.body) as Map; + expect(body['userId'], 'u1'); + expect(body['attributes'], {'plan': 'pro', 'mrr': 99}); + }); + }); + + test('flush fires 500ms after the last call, not the first', () async { + final config = await _seed(); + var calls = 0; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + calls++; + return http.Response(_userResponse('u1'), 200); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateUserId('u1') + ..processUpdates(); + async.elapse(const Duration(milliseconds: 400)); + expect(calls, 0); + + // A second call within the window resets the debounce. + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 400)); + expect(calls, 0, reason: 'only 400ms since the last call'); + + async.elapse(const Duration(milliseconds: 100)); + expect(calls, 1, reason: '500ms since the last call'); + }); + }); + + test('later attribute keys overwrite earlier ones', () async { + final config = await _seed(); + late http.Request request; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + request = req; + return http.Response(_userResponse('u1'), 200); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateUserId('u1') + ..updateAttributes({'plan': 'free'}) + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + final body = jsonDecode(request.body) as Map; + expect((body['attributes'] as Map)['plan'], 'pro'); + }); + }); + + test('userId falls back to config when not in pending updates', () async { + final config = await _seed(userId: 'u_cfg'); + late http.Request request; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + request = req; + return http.Response(_userResponse('u_cfg'), 200); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + final body = jsonDecode(request.body) as Map; + expect(body['userId'], 'u_cfg'); + }); + }); + + test('language without a userId updates local config, no API call', () async { + final config = await _seed(); + var calls = 0; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + calls++; + return http.Response(_userResponse('x'), 200); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'language': 'de'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + expect(calls, 0); + expect(config.get().user.data.language, 'de'); + expect(queue.isEmpty, isTrue); + }); + }); + + test('attributes without a userId → MissingFieldError, buffer cleared, no API', + () async { + final config = await _seed(); + var calls = 0; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + calls++; + return http.Response(_userResponse('x'), 200); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + Object? caught; + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates().catchError((Object e) => caught = e); + async.elapse(const Duration(milliseconds: 500)); + + expect(caught, isA()); + expect(calls, 0); + expect(queue.isEmpty, isTrue); + }); + }); + + test('API error → no retry, batch dropped, config user unchanged', () async { + final config = await _seed(userId: 'u1'); + final before = config.get().user.data.contactId; + var calls = 0; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + calls++; + return http.Response('{"message":"boom"}', 500); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 1500)); + + expect(calls, 1, reason: 'no retry'); + expect(queue.isEmpty, isTrue); + expect(config.get().user.data.contactId, before, reason: 'state intact'); + }); + }); + + test('response warnings suppress success log but persist state', () async { + final config = await _seed(userId: 'u1'); + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + return http.Response( + _userResponse('u1', errors: ['invalid attribute key']), + 200, + ); + }), + ); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = api; + + fakeAsync((async) { + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates(); + async.elapse(const Duration(milliseconds: 500)); + + // State persisted from the response (contactId came back as c1). + expect(config.get().user.data.contactId, 'c1'); + expect(queue.isEmpty, isTrue); + }); + }); + + test('dispose cancels the pending timer — no flush afterwards', () async { + final config = await _seed(userId: 'u1'); + var calls = 0; + final api = ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: MockClient((req) async { + calls++; + return 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: 200)); + queue.dispose(); + async.elapse(const Duration(milliseconds: 500)); + + expect(calls, 0); + }); + }); +} diff --git a/packages/formbricks_flutter/test/user/user_api_test.dart b/packages/formbricks_flutter/test/user/user_api_test.dart new file mode 100644 index 0000000..9370c56 --- /dev/null +++ b/packages/formbricks_flutter/test/user/user_api_test.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/api_client.dart'; +import 'package:formbricks_flutter/src/common/config.dart'; +import 'package:formbricks_flutter/src/common/logger.dart'; +import 'package:formbricks_flutter/src/common/setup.dart' as setup; +import 'package:formbricks_flutter/src/types/errors.dart'; +import 'package:formbricks_flutter/src/user/update_queue.dart'; +import 'package:formbricks_flutter/src/widgets/formbricks_widget.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +String _configJson() => jsonEncode({ + 'workspaceId': 'wsp_1', + 'appUrl': 'https://app.formbricks.com', + 'workspace': { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + 'user': {'expiresAt': null, 'data': {}}, + 'status': {'value': 'success', 'expiresAt': null}, + }); + +Future _seed() async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: _configJson(), + }); + FormbricksConfig.resetInstance(); + final config = FormbricksConfig.instance; + await config.init(); + return config; +} + +ApiClient _neverCalledApi(void Function() onCall) => ApiClient( + appUrl: 'https://app.formbricks.com', + workspaceId: 'wsp_1', + client: MockClient((req) async { + onCall(); + return http.Response('{}', 200); + }), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + Logger.resetInstance(); + UpdateQueue.resetInstance(); + setup.resetSetupForTest(); + }); + + tearDown(() { + UpdateQueue.resetInstance(); + setup.resetSetupForTest(); + }); + + group('not set up', () { + test('setUserId → NotSetupError, no API call', () async { + var apiCalls = 0; + final config = await _seed(); + UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _neverCalledApi(() => apiCalls++); + setup.setIsSetup(value: false); + + await expectLater( + Formbricks.setUserId('u1'), + throwsA(isA()), + ); + expect(UpdateQueue.instance.isEmpty, isTrue); + expect(apiCalls, 0); + }); + + test('setAttribute / setLanguage / logout all throw NotSetupError', + () async { + setup.setIsSetup(value: false); + + await expectLater( + Formbricks.setAttribute('plan', 'pro'), + throwsA(isA()), + ); + await expectLater( + Formbricks.setLanguage('de'), + throwsA(isA()), + ); + await expectLater( + Formbricks.logout(), + throwsA(isA()), + ); + }); + }); + + group('set up', () { + test('setUserId then setAttributes route through the queue in order', + () async { + final config = await _seed(); + UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _neverCalledApi(() {}); + setup.setIsSetup(value: true); + + final r1 = await Formbricks.setUserId('u1'); + final r2 = await Formbricks.setAttributes({'plan': 'pro'}); + + expect(r1.isOk, isTrue); + expect(r2.isOk, isTrue); + // setUserId ran first, so the attribute write resolved against userId u1. + expect(UpdateQueue.instance.pendingUserId, 'u1'); + expect(UpdateQueue.instance.pendingAttributes!['plan'], 'pro'); + }); + + test('setAttribute queues a single key', () async { + final config = await _seed(); + UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _neverCalledApi(() {}); + setup.setIsSetup(value: true); + + final result = await Formbricks.setAttribute('mrr', 99); + + expect(result.isOk, isTrue); + expect(UpdateQueue.instance.pendingAttributes!['mrr'], 99); + }); + }); +} diff --git a/packages/formbricks_flutter/test/user/user_test.dart b/packages/formbricks_flutter/test/user/user_test.dart new file mode 100644 index 0000000..f0c3706 --- /dev/null +++ b/packages/formbricks_flutter/test/user/user_test.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +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/user/update_queue.dart'; +import 'package:formbricks_flutter/src/user/user.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +String _configJson({String? userId}) => jsonEncode({ + 'workspaceId': 'wsp_1', + 'appUrl': 'https://app.formbricks.com', + 'workspace': { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + 'user': { + 'expiresAt': null, + 'data': { + 'userId': userId, + 'segments': ['seg_1'], + }, + }, + 'status': {'value': 'success', 'expiresAt': null}, + }); + +Future _seed({String? userId}) async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: _configJson(userId: userId), + }); + FormbricksConfig.resetInstance(); + final config = FormbricksConfig.instance; + await config.init(); + return config; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + Logger.resetInstance(); + UpdateQueue.resetInstance(); + }); + + tearDown(UpdateQueue.resetInstance); + + group('setUserId', () { + test('same value → Ok no-op, nothing queued, state untouched', () async { + final config = await _seed(userId: 'u1'); + final queue = UpdateQueue.instance..configOverride = config; + + final result = await setUserId('u1', config: config, queue: queue); + + expect(result.isOk, isTrue); + expect(queue.isEmpty, isTrue); + expect(config.get().user.data.userId, 'u1'); + // No teardown ran, so segments survive. + expect(config.get().user.data.segments, ['seg_1']); + }); + + test('different value when one set → tearDown then queue new id', () async { + final config = await _seed(userId: 'old'); + final queue = UpdateQueue.instance..configOverride = config; + + final result = await setUserId('new', config: config, queue: queue); + + expect(result.isOk, isTrue); + // tearDown reset user state to anonymous (userId null, segments cleared). + expect(config.get().user.data.userId, isNull); + expect(config.get().user.data.segments, isEmpty); + // New id queued for the next flush. + expect(queue.pendingUserId, 'new'); + }); + + test('from anonymous → no tearDown, id queued', () async { + final config = await _seed(); + final queue = UpdateQueue.instance..configOverride = config; + + final result = await setUserId('u1', config: config, queue: queue); + + expect(result.isOk, isTrue); + expect(queue.pendingUserId, 'u1'); + }); + }); + + group('logout', () { + test('resets user state to anonymous and persists', () async { + final config = await _seed(userId: 'u1'); + + final result = await logout(config: config); + + expect(result.isOk, isTrue); + expect(config.get().user.data.userId, isNull); + expect(config.get().user.data.segments, isEmpty); + + // Persisted to disk: a fresh load sees the reset. + final raw = + (await SharedPreferences.getInstance()).getString( + FormbricksConfig.storageKey, + ); + final decoded = jsonDecode(raw!) as Map; + expect((decoded['user'] as Map)['data']['userId'], isNull); + }); + }); +} From 999a922771512042f07bc4baf1c60e5fcf15064e Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 9 Jun 2026 17:23:59 +0530 Subject: [PATCH 2/6] fix: apply dart format and update playground test for wired identity buttons The CI format-check (and pana) flagged 5 unformatted files, and the playground widget test still asserted the old 'not wired to the SDK yet' stub which was replaced by real SDK calls. Reformat and rewrite the test to verify the new behavior: identity buttons gated off until connected, local-storage buttons always enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/playground/lib/main.dart | 68 +++++++++---------- apps/playground/test/widget_test.dart | 31 +++++++-- .../lib/src/user/attribute.dart | 3 +- .../lib/src/user/update_queue.dart | 8 +-- .../test/user/update_queue_test.dart | 6 +- .../test/user/user_test.dart | 3 +- 6 files changed, 68 insertions(+), 51 deletions(-) diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 62d36ba..496cbd6 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -198,45 +198,45 @@ class _PlaygroundHomeState extends State { if (!mounted) return; messenger ..hideCurrentSnackBar() - ..showSnackBar( - const SnackBar(content: Text('local storage cleared')), - ); + ..showSnackBar(const SnackBar(content: Text('local storage cleared'))); } @override Widget build(BuildContext context) { final identityActions = - <({String label, String action, Future> Function() op})>[ - ( - label: 'Set userId', - action: "setUserId('playground-user')", - op: () => Formbricks.setUserId('playground-user'), - ), - ( - label: 'Set User Attributes (multiple)', - action: 'setAttributes({plan, mrr, signup_date})', - op: () => Formbricks.setAttributes({ - 'plan': 'pro', - 'mrr': 99, - 'signup_date': DateTime.now(), - }), - ), - ( - label: 'Set User Attribute (single)', - action: "setAttribute('source', 'playground')", - op: () => Formbricks.setAttribute('source', 'playground'), - ), - ( - label: 'Set Language (de)', - action: "setLanguage('de')", - op: () => Formbricks.setLanguage('de'), - ), - ( - label: 'Logout', - action: 'logout()', - op: Formbricks.logout, - ), - ]; + < + ({ + String label, + String action, + Future> Function() op, + }) + >[ + ( + label: 'Set userId', + action: "setUserId('playground-user')", + op: () => Formbricks.setUserId('playground-user'), + ), + ( + label: 'Set User Attributes (multiple)', + action: 'setAttributes({plan, mrr, signup_date})', + op: () => Formbricks.setAttributes({ + 'plan': 'pro', + 'mrr': 99, + 'signup_date': DateTime.now(), + }), + ), + ( + label: 'Set User Attribute (single)', + action: "setAttribute('source', 'playground')", + op: () => Formbricks.setAttribute('source', 'playground'), + ), + ( + label: 'Set Language (de)', + action: "setLanguage('de')", + op: () => Formbricks.setLanguage('de'), + ), + (label: 'Logout', action: 'logout()', op: Formbricks.logout), + ]; final textTheme = Theme.of(context).textTheme; return Scaffold( diff --git a/apps/playground/test/widget_test.dart b/apps/playground/test/widget_test.dart index 96566fa..0d9d943 100644 --- a/apps/playground/test/widget_test.dart +++ b/apps/playground/test/widget_test.dart @@ -38,17 +38,34 @@ void main() { ); }); - testWidgets('tapping a not-yet-wired button shows the stub snackbar', ( + testWidgets('identity buttons are disabled until connected', (tester) async { + await tester.pumpWidget(const PlaygroundApp()); + + final logoutButton = tester.widget( + find.ancestor( + of: find.text('Logout'), + matching: find.byType(FilledButton), + ), + ); + // Not connected (no --dart-define credentials) → command would throw + // NotSetupError, so the button is gated off. + expect(logoutButton.onPressed, isNull); + }); + + testWidgets('local storage buttons render and are always enabled', ( tester, ) async { await tester.pumpWidget(const PlaygroundApp()); - await tester.ensureVisible(find.text('Logout')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Logout')); - await tester.pump(); - - expect(find.textContaining('not wired to the SDK yet'), findsOneWidget); + for (final label in ['Log Local Storage', 'Clear Local Storage']) { + final button = tester.widget( + find.ancestor( + of: find.text(label), + matching: find.byType(OutlinedButton), + ), + ); + expect(button.onPressed, isNotNull); + } }); testWidgets('tapping Trigger Code Action calls the wired SDK track', ( diff --git a/packages/formbricks_flutter/lib/src/user/attribute.dart b/packages/formbricks_flutter/lib/src/user/attribute.dart index 748af5b..5fc47a0 100644 --- a/packages/formbricks_flutter/lib/src/user/attribute.dart +++ b/packages/formbricks_flutter/lib/src/user/attribute.dart @@ -24,7 +24,8 @@ Future> setAttributes( }) async { final normalized = {}; attributes.forEach((key, value) { - normalized[key] = value is DateTime ? value.toUtc().toIso8601String() : value; + normalized[key] = + value is DateTime ? value.toUtc().toIso8601String() : value; }); final q = queue ?? UpdateQueue.instance; diff --git a/packages/formbricks_flutter/lib/src/user/update_queue.dart b/packages/formbricks_flutter/lib/src/user/update_queue.dart index 5a0a01b..febd4ed 100644 --- a/packages/formbricks_flutter/lib/src/user/update_queue.dart +++ b/packages/formbricks_flutter/lib/src/user/update_queue.dart @@ -143,9 +143,8 @@ class UpdateQueue { // Resolve the effective userId: pending (non-empty) first, then config. final pendingUserId = pending.userId; - final effectiveUserId = (pendingUserId.isNotEmpty) - ? pendingUserId - : cfg.user.data.userId; + final effectiveUserId = + (pendingUserId.isNotEmpty) ? pendingUserId : cfg.user.data.userId; var attributes = {...?pending.attributes}; @@ -221,8 +220,7 @@ class UpdateQueue { case Ok(:final value): // errors => always-visible; messages => debug-only. value.errors?.forEach(Logger.error); - value.messages - ?.forEach((m) => Logger.debug('User update message: $m')); + 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)); diff --git a/packages/formbricks_flutter/test/user/update_queue_test.dart b/packages/formbricks_flutter/test/user/update_queue_test.dart index ed96471..6f2a243 100644 --- a/packages/formbricks_flutter/test/user/update_queue_test.dart +++ b/packages/formbricks_flutter/test/user/update_queue_test.dart @@ -37,7 +37,8 @@ String _configJson({String? userId, String? language}) => jsonEncode({ Future _seed({String? userId, String? language}) async { SharedPreferences.setMockInitialValues({ - FormbricksConfig.storageKey: _configJson(userId: userId, language: language), + FormbricksConfig.storageKey: + _configJson(userId: userId, language: language), }); FormbricksConfig.resetInstance(); final config = FormbricksConfig.instance; @@ -223,7 +224,8 @@ void main() { }); }); - test('attributes without a userId → MissingFieldError, buffer cleared, no API', + test( + 'attributes without a userId → MissingFieldError, buffer cleared, no API', () async { final config = await _seed(); var calls = 0; diff --git a/packages/formbricks_flutter/test/user/user_test.dart b/packages/formbricks_flutter/test/user/user_test.dart index f0c3706..b390ad3 100644 --- a/packages/formbricks_flutter/test/user/user_test.dart +++ b/packages/formbricks_flutter/test/user/user_test.dart @@ -98,8 +98,7 @@ void main() { expect(config.get().user.data.segments, isEmpty); // Persisted to disk: a fresh load sees the reset. - final raw = - (await SharedPreferences.getInstance()).getString( + final raw = (await SharedPreferences.getInstance()).getString( FormbricksConfig.storageKey, ); final decoded = jsonDecode(raw!) as Map; From 384a65463bd74d014582f97412d486bc94f1444a Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 9 Jun 2026 17:50:34 +0530 Subject: [PATCH 3/6] fix(update-queue): clear pending buffer in finally so a throwing flush can't retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _sendUpdates can throw (null appUrl/workspaceId asserts, or a failing config.update disk write); the trailing _updates = null was skipped on throw, leaving the batch buffered to re-send on the next change. Wrap in try/finally and add a regression test (null workspaceId → throw). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/src/user/update_queue.dart | 10 ++++- .../test/user/update_queue_test.dart | 40 ++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/formbricks_flutter/lib/src/user/update_queue.dart b/packages/formbricks_flutter/lib/src/user/update_queue.dart index febd4ed..33b4d82 100644 --- a/packages/formbricks_flutter/lib/src/user/update_queue.dart +++ b/packages/formbricks_flutter/lib/src/user/update_queue.dart @@ -164,8 +164,14 @@ class UpdateQueue { throw MissingFieldError('userId', message: message); } - await _sendUpdates(effectiveUserId, attributes); - _updates = null; + // Clear the buffer regardless of how _sendUpdates resolves — a failed batch + // is dropped (no retry), so even an unexpected throw must not leave it + // buffered for the next change to re-send. + try { + await _sendUpdates(effectiveUserId, attributes); + } finally { + _updates = null; + } } /// Writes a queued `language` straight into local config (no API call) and diff --git a/packages/formbricks_flutter/test/user/update_queue_test.dart b/packages/formbricks_flutter/test/user/update_queue_test.dart index 6f2a243..23e9ceb 100644 --- a/packages/formbricks_flutter/test/user/update_queue_test.dart +++ b/packages/formbricks_flutter/test/user/update_queue_test.dart @@ -14,8 +14,13 @@ import 'package:shared_preferences/shared_preferences.dart'; const _appUrl = 'https://app.formbricks.com'; const _workspaceId = 'wsp_1'; -String _configJson({String? userId, String? language}) => jsonEncode({ - 'workspaceId': _workspaceId, +String _configJson({ + String? userId, + String? language, + bool omitWorkspaceId = false, +}) => + jsonEncode({ + 'workspaceId': omitWorkspaceId ? null : _workspaceId, 'appUrl': _appUrl, 'workspace': { 'expiresAt': '2100-01-01T00:00:00.000', @@ -35,10 +40,17 @@ String _configJson({String? userId, String? language}) => jsonEncode({ 'status': {'value': 'success', 'expiresAt': null}, }); -Future _seed({String? userId, String? language}) async { +Future _seed({ + String? userId, + String? language, + bool omitWorkspaceId = false, +}) async { SharedPreferences.setMockInitialValues({ - FormbricksConfig.storageKey: - _configJson(userId: userId, language: language), + FormbricksConfig.storageKey: _configJson( + userId: userId, + language: language, + omitWorkspaceId: omitWorkspaceId, + ), }); FormbricksConfig.resetInstance(); final config = FormbricksConfig.instance; @@ -282,6 +294,24 @@ void main() { }); }); + test('buffer is cleared even when _sendUpdates throws (no retry)', () async { + // A null workspaceId makes _sendUpdates throw on `cfg.workspaceId!` before + // any network call — the finally must still drop the batch. + final config = await _seed(userId: 'u1', omitWorkspaceId: true); + final queue = UpdateQueue.instance..configOverride = config; + + fakeAsync((async) { + Object? caught; + queue + ..updateAttributes({'plan': 'pro'}) + ..processUpdates().catchError((Object e) => caught = e); + async.elapse(const Duration(milliseconds: 500)); + + expect(caught, isNotNull, reason: 'the throw propagates to the caller'); + expect(queue.isEmpty, isTrue, reason: 'batch dropped, not retained'); + }); + }); + test('response warnings suppress success log but persist state', () async { final config = await _seed(userId: 'u1'); final api = ApiClient( From 2b40420b9b84789383bb5338df193e452a6f6c79 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 9 Jun 2026 17:52:58 +0530 Subject: [PATCH 4/6] test(user): stub API client so debounced flushes stay hermetic setAttributes/setUserId schedule a 500ms flush; without an apiClientOverride a slow run could fire it against the real backend before tearDown cancels the timer. Inject a no-op MockClient in attribute_test setUp and in the two setUserId tests that queue a flush. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/user/attribute_test.dart | 23 +++++++++++++++- .../test/user/user_test.dart | 27 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/formbricks_flutter/test/user/attribute_test.dart b/packages/formbricks_flutter/test/user/attribute_test.dart index b470e6a..7e20a8e 100644 --- a/packages/formbricks_flutter/test/user/attribute_test.dart +++ b/packages/formbricks_flutter/test/user/attribute_test.dart @@ -1,12 +1,31 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/api_client.dart'; import 'package:formbricks_flutter/src/common/config.dart'; import 'package:formbricks_flutter/src/common/logger.dart'; import 'package:formbricks_flutter/src/user/attribute.dart'; import 'package:formbricks_flutter/src/user/update_queue.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// A stub API client so a debounced flush never performs real network I/O. +ApiClient _noopApi() => ApiClient( + appUrl: 'https://app.formbricks.com', + workspaceId: 'wsp_1', + client: MockClient( + (_) async => http.Response( + jsonEncode({ + 'data': { + 'state': {'expiresAt': null, 'data': {}}, + }, + }), + 200, + ), + ), + ); + String _configJson() => jsonEncode({ 'workspaceId': 'wsp_1', 'appUrl': 'https://app.formbricks.com', @@ -45,7 +64,9 @@ void main() { Logger.resetInstance(); UpdateQueue.resetInstance(); config = await _seed(); - queue = UpdateQueue.instance..configOverride = config; + queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _noopApi(); }); tearDown(UpdateQueue.resetInstance); diff --git a/packages/formbricks_flutter/test/user/user_test.dart b/packages/formbricks_flutter/test/user/user_test.dart index b390ad3..ab87a5b 100644 --- a/packages/formbricks_flutter/test/user/user_test.dart +++ b/packages/formbricks_flutter/test/user/user_test.dart @@ -1,12 +1,31 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/api_client.dart'; import 'package:formbricks_flutter/src/common/config.dart'; import 'package:formbricks_flutter/src/common/logger.dart'; import 'package:formbricks_flutter/src/user/update_queue.dart'; import 'package:formbricks_flutter/src/user/user.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; +/// A stub API client so the debounced flush never performs real network I/O. +ApiClient _noopApi() => ApiClient( + appUrl: 'https://app.formbricks.com', + workspaceId: 'wsp_1', + client: MockClient( + (_) async => http.Response( + jsonEncode({ + 'data': { + 'state': {'expiresAt': null, 'data': {}}, + }, + }), + 200, + ), + ), + ); + String _configJson({String? userId}) => jsonEncode({ 'workspaceId': 'wsp_1', 'appUrl': 'https://app.formbricks.com', @@ -64,7 +83,9 @@ void main() { test('different value when one set → tearDown then queue new id', () async { final config = await _seed(userId: 'old'); - final queue = UpdateQueue.instance..configOverride = config; + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _noopApi(); final result = await setUserId('new', config: config, queue: queue); @@ -78,7 +99,9 @@ void main() { test('from anonymous → no tearDown, id queued', () async { final config = await _seed(); - final queue = UpdateQueue.instance..configOverride = config; + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _noopApi(); final result = await setUserId('u1', config: config, queue: queue); From 57270abd55c05f1c5285aa56cc2ea7b618b6315c Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 9 Jun 2026 17:58:22 +0530 Subject: [PATCH 5/6] addresses feedback --- apps/playground/lib/main.dart | 36 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 496cbd6..3d0a1fc 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -133,6 +133,8 @@ class _PlaygroundHomeState extends State { }; } on FormbricksError catch (e) { message = "track('$code') -> ${e.code.wire}: ${e.message}"; + } catch (e) { + message = "track('$code') -> unexpected error: $e"; } if (!mounted) return; setState(() => _lastTrack = message); @@ -163,6 +165,8 @@ class _PlaygroundHomeState extends State { }; } on FormbricksError catch (e) { message = '$action -> ${e.code.wire}: ${e.message}'; + } catch (e) { + message = '$action -> unexpected error: $e'; } if (!mounted) return; messenger @@ -175,30 +179,36 @@ class _PlaygroundHomeState extends State { /// Reads the persisted config and dumps it to the console. Future _logStorage(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); - final raw = await Formbricks.debugStoredConfig(); - debugPrint('Formbricks local storage: ${raw ?? ''}'); + String message; + try { + final raw = await Formbricks.debugStoredConfig(); + debugPrint('Formbricks local storage: ${raw ?? ''}'); + message = raw == null + ? 'local storage is empty' + : 'local storage logged to console (${raw.length} chars)'; + } catch (e) { + message = 'log local storage -> unexpected error: $e'; + } if (!mounted) return; messenger ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - raw == null - ? 'local storage is empty' - : 'local storage logged to console (${raw.length} chars)', - ), - ), - ); + ..showSnackBar(SnackBar(content: Text(message))); } /// Clears the persisted config + in-memory copy. Future _clearStorage(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); - await Formbricks.debugClearStoredConfig(); + String message; + try { + await Formbricks.debugClearStoredConfig(); + message = 'local storage cleared'; + } catch (e) { + message = 'clear local storage -> unexpected error: $e'; + } if (!mounted) return; messenger ..hideCurrentSnackBar() - ..showSnackBar(const SnackBar(content: Text('local storage cleared'))); + ..showSnackBar(SnackBar(content: Text(message))); } @override From a66ed539258a9eae940b97a983dc43e36cd7bb14 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 10 Jun 2026 10:46:55 +0530 Subject: [PATCH 6/6] fixes feedback --- .../lib/src/user/attribute.dart | 5 ++- .../lib/src/user/update_queue.dart | 33 +++++++++++-------- .../formbricks_flutter/lib/src/user/user.dart | 16 +++++++-- .../test/user/update_queue_test.dart | 4 +-- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/formbricks_flutter/lib/src/user/attribute.dart b/packages/formbricks_flutter/lib/src/user/attribute.dart index 5fc47a0..b69567d 100644 --- a/packages/formbricks_flutter/lib/src/user/attribute.dart +++ b/packages/formbricks_flutter/lib/src/user/attribute.dart @@ -30,7 +30,10 @@ Future> setAttributes( final q = queue ?? UpdateQueue.instance; q.updateAttributes(normalized); - unawaited(q.processUpdates()); + // Fire-and-forget: the flush already logs and recovers from failures (e.g. + // MissingFieldError), so swallow here to keep it off the host's unhandled- + // error path. Tests await processUpdates() directly and still see the error. + unawaited(q.processUpdates().catchError((_) {})); return const Result.ok(null); } diff --git a/packages/formbricks_flutter/lib/src/user/update_queue.dart b/packages/formbricks_flutter/lib/src/user/update_queue.dart index 33b4d82..773e0c8 100644 --- a/packages/formbricks_flutter/lib/src/user/update_queue.dart +++ b/packages/formbricks_flutter/lib/src/user/update_queue.dart @@ -123,9 +123,14 @@ class UpdateQueue { return completer.future; } - /// Cancels any pending flush and clears the buffer. Call on disposal/hot - /// reload so a dangling [Timer] can't fire after the queue is gone. - void dispose() { + /// Cancels any pending flush and drops the buffered updates without sending. + /// + /// Call on logout / identity switch so a queued change can't re-identify the + /// user after logout, nor leak attributes queued for one user into the next. + /// Also the disposal/hot-reload path: a dangling [Timer] can't fire after the + /// queue is cleared. The shared completer is resolved (not errored) so + /// fire-and-forget callers don't see a dropped batch as a failure. + void clear() { _debounce?.cancel(); _debounce = null; final c = _flushCompleter; @@ -139,6 +144,13 @@ class UpdateQueue { final pending = _updates; if (pending == null) return; + // Snapshot, then drop the live buffer *before* any await. Anything queued + // during the round-trip starts a fresh batch instead of writing into the + // one in flight (which would otherwise get re-sent by an overlapping flush + // or silently wiped by this flush's exit). Failed batches are dropped (no + // retry) anyway, so clearing up-front changes no semantics. + _updates = null; + final cfg = _config.get(); // Resolve the effective userId: pending (non-empty) first, then config. @@ -153,25 +165,18 @@ class UpdateQueue { attributes = await _handleLanguageWithoutUserId(attributes); } - // Attributes require a userId. If any remain without one, error and clear. + // Attributes require a userId. If any remain without one, error out. The + // buffer is already cleared above, so nothing re-sends. if (attributes.isNotEmpty && (effectiveUserId == null || effectiveUserId.isEmpty)) { const message = "Formbricks can't set attributes without a userId! Please set a " 'userId first with the setUserId function'; Logger.error(message); - _updates = null; throw MissingFieldError('userId', message: message); } - // Clear the buffer regardless of how _sendUpdates resolves — a failed batch - // is dropped (no retry), so even an unexpected throw must not leave it - // buffered for the next change to re-send. - try { - await _sendUpdates(effectiveUserId, attributes); - } finally { - _updates = null; - } + await _sendUpdates(effectiveUserId, attributes); } /// Writes a queued `language` straight into local config (no API call) and @@ -240,7 +245,7 @@ class UpdateQueue { /// Drops the singleton so each test starts clean. Test-only. @visibleForTesting static void resetInstance() { - _instance?.dispose(); + _instance?.clear(); _instance = null; } } diff --git a/packages/formbricks_flutter/lib/src/user/user.dart b/packages/formbricks_flutter/lib/src/user/user.dart index 0a9c54b..e082d8a 100644 --- a/packages/formbricks_flutter/lib/src/user/user.dart +++ b/packages/formbricks_flutter/lib/src/user/user.dart @@ -42,17 +42,29 @@ Future> setUserId( Logger.debug( '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. + q.clear(); await tearDown(config: cfg); } q.updateUserId(userId); - unawaited(q.processUpdates()); + // Fire-and-forget: the flush already logs and recovers from failures (e.g. + // MissingFieldError), so swallow here to keep it off the host's unhandled- + // error path. Tests await processUpdates() directly and still see the error. + unawaited(q.processUpdates().catchError((_) {})); return const Result.ok(null); } /// Logs the current user out, resetting user state to anonymous. -Future> logout({FormbricksConfig? config}) async { +Future> logout({ + FormbricksConfig? config, + UpdateQueue? queue, +}) 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. + (queue ?? UpdateQueue.instance).clear(); await tearDown(config: config ?? FormbricksConfig.instance); return const Result.ok(null); } diff --git a/packages/formbricks_flutter/test/user/update_queue_test.dart b/packages/formbricks_flutter/test/user/update_queue_test.dart index 23e9ceb..8936694 100644 --- a/packages/formbricks_flutter/test/user/update_queue_test.dart +++ b/packages/formbricks_flutter/test/user/update_queue_test.dart @@ -340,7 +340,7 @@ void main() { }); }); - test('dispose cancels the pending timer — no flush afterwards', () async { + test('clear cancels the pending timer — no flush afterwards', () async { final config = await _seed(userId: 'u1'); var calls = 0; final api = ApiClient( @@ -360,7 +360,7 @@ void main() { ..updateAttributes({'plan': 'pro'}) ..processUpdates(); async.elapse(const Duration(milliseconds: 200)); - queue.dispose(); + queue.clear(); async.elapse(const Duration(milliseconds: 500)); expect(calls, 0);