diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 7c3f765..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); @@ -143,26 +145,108 @@ 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}'; + } catch (e) { + message = '$action -> unexpected error: $e'; + } + if (!mounted) return; + messenger ..hideCurrentSnackBar() ..showSnackBar( - SnackBar( - content: Text('$action - not wired to the SDK yet'), - duration: const Duration(seconds: 1), - ), + 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); + 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(message))); + } + + /// Clears the persisted config + in-memory copy. + Future _clearStorage(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + 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(SnackBar(content: Text(message))); + } + @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; return Scaffold( @@ -250,14 +334,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/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/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..b69567d --- /dev/null +++ b/packages/formbricks_flutter/lib/src/user/attribute.dart @@ -0,0 +1,48 @@ +/// 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); + // 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); +} + +/// 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..773e0c8 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/user/update_queue.dart @@ -0,0 +1,251 @@ +/// 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 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; + _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; + + // 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. + 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 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); + throw MissingFieldError('userId', message: message); + } + + await _sendUpdates(effectiveUserId, attributes); + } + + /// 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?.clear(); + _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..e082d8a --- /dev/null +++ b/packages/formbricks_flutter/lib/src/user/user.dart @@ -0,0 +1,70 @@ +/// 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', + ); + // 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); + // 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, + 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/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..7e20a8e --- /dev/null +++ b/packages/formbricks_flutter/test/user/attribute_test.dart @@ -0,0 +1,95 @@ +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', + '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 + ..apiClientOverride = _noopApi(); + }); + + 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..8936694 --- /dev/null +++ b/packages/formbricks_flutter/test/user/update_queue_test.dart @@ -0,0 +1,369 @@ +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, + bool omitWorkspaceId = false, +}) => + jsonEncode({ + 'workspaceId': omitWorkspaceId ? null : _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, + bool omitWorkspaceId = false, +}) async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: _configJson( + userId: userId, + language: language, + omitWorkspaceId: omitWorkspaceId, + ), + }); + 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('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( + 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('clear 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.clear(); + 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..ab87a5b --- /dev/null +++ b/packages/formbricks_flutter/test/user/user_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/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', + '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 + ..apiClientOverride = _noopApi(); + + 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 + ..apiClientOverride = _noopApi(); + + 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); + }); + }); +}