diff --git a/README.md b/README.md index 659f61e..dd52496 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,18 @@ await MatomoTracker.instance.initialize( When using cookieless tracking, neither the user_id nor the first_visit will be sent or saved locally. +## Dispatching + +Actions logged are not send to Matomo immediately, but are queued for a configurable duration (defaulting to 10 seconds) before beeing send in batch. A user could terminate your app while there are still undispatched actions in the queue, which by default would be lost. The queue can be configured to be persistent so that such actions would then be send on the next app launch. See the [`DispatchSettings`](https://pub.dev/documentation/matomo_tracker/latest/matomo_tracker/DispatchSettings-class.html) class for more configuration options. + +```dart +await MatomoTracker.instance.initialize( + siteId: siteId, + url: 'https://example.com/matomo.php', + dispatchSettings: const DispatchSettings.persistent(), +); +``` + # Migration Guide ## v4.0.0 diff --git a/example/lib/main.dart b/example/lib/main.dart index ca570aa..c545242 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -9,12 +9,21 @@ const _matomoEndpoint = 'http://localhost:8765/matomo.php'; const _sideId = 1; const _testUserId = 'Nelson Pandela'; +// Use this as dispatchSettings in MatomoTracker.instance.initialize() +// to test persistent actions. Then run the example, cause some actions +// by clicking around, close the example within 5min to prevent the +// dispatchment of the actions, run the example again, wait at least 5min, +// finally check the Matomo dashboard to see if all actions are there. +const DispatchSettings dispatchSettingsEndToEndTest = + DispatchSettings.persistent(dequeueInterval: Duration(minutes: 5)); + void main() async { WidgetsFlutterBinding.ensureInitialized(); await MatomoTracker.instance.initialize( siteId: _sideId, url: _matomoEndpoint, verbosityLevel: Level.all, + // dispatchSettings: dispatchSettingsEndToEndTest, ); MatomoTracker.instance.setVisitorUserId(_testUserId); runApp(const MyApp()); diff --git a/lib/matomo_tracker.dart b/lib/matomo_tracker.dart index dea3d05..83a5c4c 100644 --- a/lib/matomo_tracker.dart +++ b/lib/matomo_tracker.dart @@ -4,6 +4,7 @@ library matomo_tracker; export 'src/campaign.dart'; export 'src/content.dart'; +export 'src/dispatch_settings.dart'; export 'src/event_info.dart'; export 'src/exceptions.dart'; export 'src/local_storage/local_storage.dart'; diff --git a/lib/src/dispatch_settings.dart b/lib/src/dispatch_settings.dart new file mode 100644 index 0000000..dfc722c --- /dev/null +++ b/lib/src/dispatch_settings.dart @@ -0,0 +1,130 @@ +bool _whereNotOlderThanADay(Map action) => + DispatchSettings.whereNotOlderThan( + const Duration( + hours: 23, + minutes: 59, + seconds: 59, + ), + )(action); + +bool _notOlderThan( + Map action, + DateTime now, + Duration duration, +) { + if (action['cdt'] case final date?) { + return now.difference(DateTime.parse(date)) <= duration; + } + return false; +} + +bool _takeAll(Map action) => true; + +bool _dropAll(Map action) => false; + +/// Used to filter out unwanted actions of the last session if using +/// [DispatchSettings.persistent]. +/// +/// Will invoked with the serialized `action` and should return `true` if the +/// action is still valid, or `false` if the action should be dropped. +/// +/// Filters can be chained using [DispatchSettings.chain]. +/// +/// Some build in filters are [DispatchSettings.takeAll], +/// [DispatchSettings.dropAll], [DispatchSettings.whereUserId], +/// [DispatchSettings.whereNotOlderThan] and +/// [DispatchSettings.whereNotOlderThanADay]. +typedef PersistenceFilter = bool Function(Map action); + +/// Controls the behaviour of dispatching actions to Matomo. +class DispatchSettings { + /// Uses a persistent dispatch queue. + /// + /// This means that if the app terminates while there are still undispatched + /// actions, those actions are dispatched on next app launch. + /// + /// The [onLoad] can be used to filter the stored actions and drop outdated + /// ones. By default, only actions that are younger then a day are retained. + /// See [PersistenceFilter] for some build in filters. + const DispatchSettings.persistent({ + Duration dequeueInterval = defaultDequeueInterval, + PersistenceFilter onLoad = whereNotOlderThanADay, + }) : this._( + dequeueInterval, + true, + onLoad, + ); + + /// Uses a non persistent dispatch queue. + /// + /// This means that if the app terminates while there are still undispatched + /// actions, those actions are lost. + const DispatchSettings.nonPersistent({ + Duration dequeueInterval = defaultDequeueInterval, + }) : this._( + dequeueInterval, + false, + null, + ); + + const DispatchSettings._( + this.dequeueInterval, + this.persistentQueue, + this.onLoad, + ); + + /// The default duration between dispatching actions to the Matomo backend. + static const Duration defaultDequeueInterval = Duration( + seconds: 10, + ); + + /// Takes all actions. + static const PersistenceFilter takeAll = _takeAll; + + /// Drops all actions. + static const PersistenceFilter dropAll = _dropAll; + + /// Only takes actions where the userId is `uid`. + static PersistenceFilter whereUserId(String uid) { + return (Map action) => action['uid'] == uid; + } + + /// Only takes actions that are not older than `duration`. + static PersistenceFilter whereNotOlderThan(Duration duration) => + (Map action) => _notOlderThan( + action, + DateTime.now(), + duration, + ); + + /// Shorthand for [whereNotOlderThan] with a duration of a day. + static const PersistenceFilter whereNotOlderThanADay = _whereNotOlderThanADay; + + /// Combines multiple [PersistenceFilter]s. + /// + /// The returned filter is eager, which means that it will return `false` + /// immediately and not check the reaming filters once a filter returned + /// `false`. + static PersistenceFilter chain(Iterable filters) { + return filters.fold(takeAll, (previousValue, element) { + return (Map action) { + if (previousValue(action)) { + return element(action); + } else { + return false; + } + }; + }); + } + + /// How often to dispatch actions to the Matomo backend. + final Duration dequeueInterval; + + /// Wheter to store actions persistently before dispatching. + final bool persistentQueue; + + /// Used to determine which of the stored actions are still valid. + /// + /// Will not be `null` if [persistentQueue] is `true`, or `null` if `false`. + final PersistenceFilter? onLoad; +} diff --git a/lib/src/local_storage/cookieless_storage.dart b/lib/src/local_storage/cookieless_storage.dart index ab54d72..7139850 100644 --- a/lib/src/local_storage/cookieless_storage.dart +++ b/lib/src/local_storage/cookieless_storage.dart @@ -41,4 +41,11 @@ class CookielessStorage implements LocalStorage { @override Future setVisitorId(String _) => Future.value(); + + @override + Future loadActions() => storage.loadActions(); + + @override + Future storeActions(String serializedActions) => + storage.storeActions(serializedActions); } diff --git a/lib/src/local_storage/local_storage.dart b/lib/src/local_storage/local_storage.dart index 160c0b5..2844faf 100644 --- a/lib/src/local_storage/local_storage.dart +++ b/lib/src/local_storage/local_storage.dart @@ -7,6 +7,8 @@ abstract class LocalStorage { Future setVisitCount(int visitCount); Future getOptOut(); Future setOptOut({required bool optOut}); + Future storeActions(String serializedActions); + Future loadActions(); /// {@template local_storage.clear} /// Clear the following data from the local storage: @@ -14,6 +16,7 @@ abstract class LocalStorage { /// - First visit /// - Number of visits /// - Visitor ID + /// - Action Queue /// {@endtemplate} Future clear(); } diff --git a/lib/src/local_storage/shared_prefs_storage.dart b/lib/src/local_storage/shared_prefs_storage.dart index 7a7f8db..c674b05 100644 --- a/lib/src/local_storage/shared_prefs_storage.dart +++ b/lib/src/local_storage/shared_prefs_storage.dart @@ -7,6 +7,7 @@ class SharedPrefsStorage implements LocalStorage { static const kVisitCount = 'matomo_visit_count'; static const kVisitorId = 'matomo_visitor_id'; static const kOptOut = 'matomo_opt_out'; + static const kPersistentQueue = 'matomo_persistent_queue'; SharedPreferences? _prefs; @@ -79,6 +80,19 @@ class SharedPrefsStorage implements LocalStorage { prefs.remove(kFirstVisit), prefs.remove(kVisitCount), prefs.remove(kVisitorId), + prefs.remove(kPersistentQueue), ]); } + + @override + Future loadActions() async { + final prefs = await _getSharedPrefs(); + return prefs.getString(kPersistentQueue); + } + + @override + Future storeActions(String serializedActions) async { + final prefs = await _getSharedPrefs(); + await prefs.setString(kPersistentQueue, serializedActions); + } } diff --git a/lib/src/matomo.dart b/lib/src/matomo.dart index 718e65a..cda2ad3 100644 --- a/lib/src/matomo.dart +++ b/lib/src/matomo.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:matomo_tracker/src/assert.dart'; import 'package:matomo_tracker/src/campaign.dart'; import 'package:matomo_tracker/src/content.dart'; +import 'package:matomo_tracker/src/dispatch_settings.dart'; import 'package:matomo_tracker/src/event_info.dart'; import 'package:matomo_tracker/src/exceptions.dart'; import 'package:matomo_tracker/src/local_storage/cookieless_storage.dart'; @@ -18,6 +19,7 @@ import 'package:matomo_tracker/src/logger/logger.dart'; import 'package:matomo_tracker/src/matomo_action.dart'; import 'package:matomo_tracker/src/matomo_dispatcher.dart'; import 'package:matomo_tracker/src/performance_info.dart'; +import 'package:matomo_tracker/src/persistent_queue.dart'; import 'package:matomo_tracker/src/platform_info/platform_info.dart'; import 'package:matomo_tracker/src/session.dart'; import 'package:matomo_tracker/src/tracking_order_item.dart'; @@ -121,7 +123,7 @@ class MatomoTracker { late final LocalStorage _localStorage; @visibleForTesting - final queue = Queue>(); + late final Queue> queue; @visibleForTesting late Timer dequeueTimer; @@ -135,7 +137,8 @@ class MatomoTracker { String? get authToken => _tokenAuth; - late final Duration _dequeueInterval; + /// Controls how actions are dispatched. + late final DispatchSettings _dispatchSettings; late final Duration? _pingInterval; @@ -179,7 +182,7 @@ class MatomoTracker { String? visitorId, String? uid, String? contentBaseUrl, - Duration dequeueInterval = const Duration(seconds: 10), + DispatchSettings dispatchSettings = const DispatchSettings.nonPersistent(), Duration? pingInterval = const Duration(seconds: 30), String? tokenAuth, LocalStorage? localStorage, @@ -203,15 +206,20 @@ class MatomoTracker { ); } - assertDurationNotNegative(value: dequeueInterval, name: 'dequeueInterval'); - assertDurationNotNegative(value: pingInterval, name: 'pingInterval'); + assertDurationNotNegative( + value: dispatchSettings.dequeueInterval, + name: 'dequeueInterval', + ); + assertDurationNotNegative( + value: pingInterval, + name: 'pingInterval', + ); log.setLogging(level: verbosityLevel); this.siteId = siteId; this.url = url; this.customHeaders = customHeaders; - _dequeueInterval = dequeueInterval; _pingInterval = pingInterval; _lock = sync.Lock(); _platformInfo = platformInfo ?? PlatformInfo.instance; @@ -219,11 +227,18 @@ class MatomoTracker { _tokenAuth = tokenAuth; _newVisit = newVisit; this.attachLastPvId = attachLastPvId; + _dispatchSettings = dispatchSettings; final effectiveLocalStorage = localStorage ?? SharedPrefsStorage(); _localStorage = cookieless ? CookielessStorage(storage: effectiveLocalStorage) : effectiveLocalStorage; + queue = _dispatchSettings.persistentQueue + ? await PersistentQueue.load( + storage: _localStorage, + onLoadFilter: _dispatchSettings.onLoad!, + ) + : Queue(); final localVisitorId = visitorId ?? await _getVisitorId(); _visitor = Visitor(id: localVisitorId, uid: uid); @@ -288,7 +303,7 @@ class MatomoTracker { ); _initialized = true; - dequeueTimer = Timer.periodic(_dequeueInterval, (_) { + dequeueTimer = Timer.periodic(_dispatchSettings.dequeueInterval, (_) { _dequeue(); }); @@ -297,6 +312,10 @@ class MatomoTracker { _ping(); }); } + + if (queue.isNotEmpty) { + await dispatchActions(); + } } @visibleForTesting @@ -352,8 +371,9 @@ class MatomoTracker { /// {@macro local_storage.clear} void clear() => _localStorage.clear(); - /// Cancel the timer which checks the queued actions to send. (This will not - /// clear the queue.) + /// Cancel the timer which checks the queued actions to send + /// + /// This will not clear the queue. void dispose() { pingTimer?.cancel(); dequeueTimer.cancel(); @@ -379,7 +399,7 @@ class MatomoTracker { } } if (!dequeueTimer.isActive) { - dequeueTimer = Timer.periodic(_dequeueInterval, (timer) { + dequeueTimer = Timer.periodic(_dispatchSettings.dequeueInterval, (timer) { _dequeue(); }); } diff --git a/lib/src/persistent_queue.dart b/lib/src/persistent_queue.dart new file mode 100644 index 0000000..dd27b74 --- /dev/null +++ b/lib/src/persistent_queue.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matomo_tracker/src/dispatch_settings.dart'; +import 'package:matomo_tracker/src/local_storage/local_storage.dart'; + +class PersistentQueue extends DelegatingQueue> { + PersistentQueue._(this._storage, Queue> base) + : _needsSave = false, + super(base); + + static Future load({ + required LocalStorage storage, + required PersistenceFilter onLoadFilter, + }) async { + final actionsData = await storage.loadActions(); + final actions = decode(actionsData)..retainWhere(onLoadFilter); + final queue = PersistentQueue._(storage, Queue.of(actions)); + await queue.save(); + return queue; + } + + @visibleForTesting + static List> decode(String? actionsData) { + if (actionsData != null) { + return (json.decode(actionsData) as List) + .cast>() + .map>((element) => element.cast()) + .toList(); + } else { + return []; + } + } + + final LocalStorage _storage; + bool _needsSave; + Completer? _saveInProgress; + @visibleForTesting + bool get saveInProgress => _saveInProgress != null; + + @visibleForTesting + Future save() { + _needsSave = true; + final effectiveSaveInProgress = _saveInProgress ?? Completer(); + if (effectiveSaveInProgress != _saveInProgress) { + assert(_saveInProgress == null); + _saveInProgress = effectiveSaveInProgress; + unawaited( + Future(() async { + try { + while (_needsSave) { + _needsSave = false; + final data = json.encode(toList()); + await _storage.storeActions(data); + } + effectiveSaveInProgress.complete(); + } catch (error, stackTrace) { + effectiveSaveInProgress.completeError(error, stackTrace); + } finally { + _saveInProgress = null; + } + }), + ); + } + return effectiveSaveInProgress.future; + } + + void _unawaitedSave() => unawaited(save()); + + @override + void add(Map value) { + super.add(value); + _unawaitedSave(); + } + + @override + void addAll(Iterable> iterable) { + super.addAll(iterable); + _unawaitedSave(); + } + + @override + void addFirst(Map value) { + super.addFirst(value); + _unawaitedSave(); + } + + @override + void addLast(Map value) { + super.addLast(value); + _unawaitedSave(); + } + + @override + void clear() { + super.clear(); + _unawaitedSave(); + } + + @override + bool remove(Object? object) { + final result = super.remove(object); + _unawaitedSave(); + return result; + } + + @override + Map removeFirst() { + final result = super.removeFirst(); + _unawaitedSave(); + return result; + } + + @override + Map removeLast() { + final result = super.removeLast(); + _unawaitedSave(); + return result; + } + + @override + void removeWhere(bool Function(Map element) test) { + super.removeWhere(test); + _unawaitedSave(); + } + + @override + void retainWhere(bool Function(Map element) test) { + super.retainWhere(test); + _unawaitedSave(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2ae59dd..5450c1c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: clock: ^1.1.1 + collection: ^1.17.1 device_info_plus: ^9.0.2 flutter: sdk: flutter diff --git a/test/src/dispatch_settings_test.dart b/test/src/dispatch_settings_test.dart new file mode 100644 index 0000000..e1d46ea --- /dev/null +++ b/test/src/dispatch_settings_test.dart @@ -0,0 +1,174 @@ +import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:matomo_tracker/src/dispatch_settings.dart'; +import 'package:matomo_tracker/src/matomo_action.dart'; +import 'package:mocktail/mocktail.dart'; +import '../ressources/mock/data.dart'; +import '../ressources/mock/mock.dart'; + +const _uid1 = 'user1'; +const _uid2 = 'user2'; + +const _deepCollectionEquality = DeepCollectionEquality(); +bool deepEquals(Object? a, Object? b) => _deepCollectionEquality.equals(a, b); + +void main() { + group('DispatchSettings', () { + test('it should be able to create non-persistent DispatchSettings', () { + const settings = DispatchSettings.nonPersistent(); + expect(settings.persistentQueue, false); + expect(settings.onLoad, isNull); + }); + + test('it should be able to create persistent DispatchSettings', () { + const settings = DispatchSettings.persistent(); + expect(settings.persistentQueue, true); + expect(settings.onLoad, isNotNull); + }); + }); + + group('PersistenceFilter', () { + setUpAll(() { + when(() => mockMatomoTracker.visitor).thenReturn(mockVisitor); + when(() => mockMatomoTracker.session).thenReturn(mockSession); + when(() => mockMatomoTracker.screenResolution) + .thenReturn(matomoTrackerScreenResolution); + when(() => mockMatomoTracker.contentBase) + .thenReturn(matomoTrackerContentBase); + when(() => mockMatomoTracker.siteId).thenReturn(matomoTrackerSiteId); + when(() => mockVisitor.id).thenReturn(visitorId); + when(() => mockVisitor.uid).thenReturn(uid); + when(mockTrackingOrderItem.toArray).thenReturn([]); + when(() => mockSession.visitCount).thenReturn(sessionVisitCount); + when(() => mockSession.lastVisit).thenReturn(sessionLastVisite); + when(() => mockSession.firstVisit).thenReturn(sessionFirstVisite); + }); + + Map recentUser1(DateTime now) => + withClock(Clock.fixed(now.add(const Duration(hours: -5))), () { + when(() => mockVisitor.uid).thenReturn(_uid1); + return MatomoAction().toMap(mockMatomoTracker)..remove('rand'); + }); + + Map oldUser2(DateTime now) => + withClock(Clock.fixed(now.add(const Duration(hours: -5, days: -1))), + () { + when(() => mockVisitor.uid).thenReturn(_uid2); + return MatomoAction().toMap(mockMatomoTracker)..remove('rand'); + }); + + List> getStoredActions(DateTime now) => + [oldUser2(now), recentUser1(now)]; + + test('takeAll should not change the action list', () { + final now = DateTime.now(); + final before = getStoredActions(now); + final after = getStoredActions(now) + ..retainWhere(DispatchSettings.takeAll); + expect(deepEquals(before, after), isTrue); + }); + + test('dropAll should produce an empty action list', () { + final actions = getStoredActions(DateTime.now()) + ..retainWhere(DispatchSettings.dropAll); + expect(actions, isEmpty); + }); + + test('whereNotOlderThanADay should only retain the recent action', () { + final now = DateTime.now(); + final recent = recentUser1(now); + final after = getStoredActions(now) + ..retainWhere(DispatchSettings.whereNotOlderThanADay); + expect(deepEquals([recent], after), isTrue); + }); + + test( + 'whereNotOlderThan should only retain some actions based on the duration', + () { + final now = DateTime.now(); + final recent = recentUser1(now); + final old = oldUser2(now); + final unchanged = getStoredActions(now) + ..retainWhere( + DispatchSettings.whereNotOlderThan(const Duration(days: 2)), + ); + final onlyRecent = getStoredActions(now) + ..retainWhere( + DispatchSettings.whereNotOlderThan(const Duration(days: 1)), + ); + final none = getStoredActions(now) + ..retainWhere( + DispatchSettings.whereNotOlderThan(Duration.zero), + ); + expect(deepEquals([old, recent], unchanged), isTrue); + expect(deepEquals([recent], onlyRecent), isTrue); + expect(none, isEmpty); + }); + + test('whereUserId should only retain actions of the specific user', () { + final now = DateTime.now(); + final uid1 = recentUser1(now); + final afterUid1 = getStoredActions(now) + ..retainWhere(DispatchSettings.whereUserId(_uid1)); + final uid2 = oldUser2(now); + final afterUid2 = getStoredActions(now) + ..retainWhere(DispatchSettings.whereUserId(_uid2)); + expect(deepEquals([uid1], afterUid1), isTrue); + expect(deepEquals([uid2], afterUid2), isTrue); + }); + + test('chain should chain filters together', () { + final now = DateTime.now(); + final recent = recentUser1(now); + final old = oldUser2(now); + final chained = DispatchSettings.chain( + [ + // will filter out recent + DispatchSettings.whereUserId(_uid2), + // will filter out old + DispatchSettings.whereNotOlderThanADay + ], + ); + final after = [old, recent]..retainWhere(chained); + expect(after, isEmpty); + }); + + test( + 'chaining only one filter should result in the same result as the filter', + () { + final now = DateTime.now(); + final chained = DispatchSettings.chain( + [DispatchSettings.whereNotOlderThanADay], + ); + final withChained = getStoredActions(now)..retainWhere(chained); + final withoutChain = getStoredActions(now) + ..retainWhere(DispatchSettings.whereNotOlderThanADay); + expect( + deepEquals( + withChained, + withoutChain, + ), + isTrue, + ); + }); + + test('chain should stop eagerly', () { + final now = DateTime.now(); + bool alwaysThrows(Map action) => + throw StateError('Unreachable'); + final produceEmpty = DispatchSettings.chain( + [DispatchSettings.dropAll, alwaysThrows], + ); + final produceError = DispatchSettings.chain( + [DispatchSettings.takeAll, alwaysThrows], + ); + final empty = getStoredActions(now)..retainWhere(produceEmpty); + expect(empty, isEmpty); + expect( + () => getStoredActions(now).retainWhere(produceError), + throwsStateError, + ); + }); + }); +} diff --git a/test/src/local_storage/shared_prefs_storage_test.dart b/test/src/local_storage/shared_prefs_storage_test.dart index 8156b11..c0af514 100644 --- a/test/src/local_storage/shared_prefs_storage_test.dart +++ b/test/src/local_storage/shared_prefs_storage_test.dart @@ -91,13 +91,29 @@ void main() { }); test( - 'clear should call remove on kFirstVisit, kVisitCount and kVisitorId', + 'clear should call remove on kFirstVisit, kVisitCount, kVisitorId and kPersistentQueue', () async { await sharedPrefsStorage.clear(); verify(() => mockPrefs.remove(SharedPrefsStorage.kFirstVisit)); verify(() => mockPrefs.remove(SharedPrefsStorage.kVisitCount)); verify(() => mockPrefs.remove(SharedPrefsStorage.kVisitorId)); + verify(() => mockPrefs.remove(SharedPrefsStorage.kPersistentQueue)); }, ); + + test('loadActions should call getString on kPersistentQueue', () async { + await sharedPrefsStorage.loadActions(); + verify(() => mockPrefs.getString(SharedPrefsStorage.kPersistentQueue)); + }); + + test('storeActions should call setString', () async { + await sharedPrefsStorage.storeActions(matomoTrackerVisitorId); + verify( + () => mockPrefs.setString( + SharedPrefsStorage.kPersistentQueue, + matomoTrackerVisitorId, + ), + ); + }); }); } diff --git a/test/src/persistent_queue_test.dart b/test/src/persistent_queue_test.dart new file mode 100644 index 0000000..eebf196 --- /dev/null +++ b/test/src/persistent_queue_test.dart @@ -0,0 +1,292 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:matomo_tracker/matomo_tracker.dart'; +import 'package:matomo_tracker/src/local_storage/shared_prefs_storage.dart'; +import 'package:matomo_tracker/src/persistent_queue.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dispatch_settings_test.dart'; +import 'local_storage/shared_prefs_storage_test.dart'; + +typedef QueueCall = T Function(Queue> queue); +typedef AdditionalExpect = void Function( + PersistentQueue original, + PersistentQueue restored, + Queue> reference, +); + +bool _action1Filter(Map action) => + action['action1'] == 'value1'; +const _nonEmptyJson = '[{"action1":"value1"},{"action2":"value2"}]'; +const _nonEmptyJsonElementCount = 2; +const _action = {'action3': 'value3'}; +const _actions = [ + _action, + {'action4': 'value4'} +]; + +void main() { + group('decode', () { + test('it should be able to decode an empty list', () { + final decoded = PersistentQueue.decode('[]'); + expect(decoded, isEmpty); + }); + + test('decoding null should result in an empty list', () { + final decoded = PersistentQueue.decode(null); + expect(decoded, isEmpty); + }); + + test('it should be able to decode a stored list', () { + final decoded = PersistentQueue.decode(_nonEmptyJson); + expect(decoded.length, _nonEmptyJsonElementCount); + }); + }); + + group('load', () { + test('load should apply the onLoad PersistenceFilter', () async { + SharedPreferences.setMockInitialValues( + {SharedPrefsStorage.kPersistentQueue: _nonEmptyJson}, + ); + final sharedPrefsStorage = SharedPrefsStorage(); + final takeAllQueue = await PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.takeAll, + ); + expect(takeAllQueue.length, _nonEmptyJsonElementCount); + final dropAllQueue = await PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.dropAll, + ); + expect(dropAllQueue, isEmpty); + }); + + test('load should create an empty queue if no data was stored', () async { + SharedPreferences.setMockInitialValues({}); + final sharedPrefsStorage = SharedPrefsStorage(); + final persistentQueue = await PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.takeAll, + ); + expect(persistentQueue, isEmpty); + }); + + test('if data were stored, load should recreate a deep equal queue', + () async { + SharedPreferences.setMockInitialValues( + {SharedPrefsStorage.kPersistentQueue: _nonEmptyJson}, + ); + final sharedPrefsStorage = SharedPrefsStorage(); + final persistentQueue = await PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.takeAll, + ); + expect(persistentQueue.length, _nonEmptyJsonElementCount); + expect( + deepEquals( + json.decode(_nonEmptyJson), + persistentQueue.toList(), + ), + isTrue, + ); + }); + }); + + group('save', () { + late SharedPrefsStorage sharedPrefsStorage; + setUp(() { + SharedPreferences.setMockInitialValues( + {SharedPrefsStorage.kPersistentQueue: _nonEmptyJson}, + ); + sharedPrefsStorage = SharedPrefsStorage(); + }); + + Future load() => PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.takeAll, + ); + + test( + 'multiple unawaited invokations of save should result in a identical future', + () async { + final persistentQueue = await load(); + var last = persistentQueue.save(); + expect(persistentQueue.saveInProgress, isTrue); + for (int i = 0; i < 10; i++) { + final next = persistentQueue.save(); + expect(identical(last, next), isTrue); + last = next; + } + await last; + expect(persistentQueue.saveInProgress, isFalse); + }, + ); + + test( + 'multiple awaited invokations of save should result in different futures', + () async { + final persistentQueue = await load(); + var last = persistentQueue.save(); + expect(persistentQueue.saveInProgress, isTrue); + for (int i = 0; i < 10; i++) { + await last; + final next = persistentQueue.save(); + expect(identical(last, next), isFalse); + last = next; + } + await last; + expect(persistentQueue.saveInProgress, isFalse); + }, + ); + + test('save should write data to the local storage', () async { + final mockPrefs = MockSharedPreferences(); + when(() => mockPrefs.getString(any())).thenReturn(null); + when(() => mockPrefs.setString(any(), any())) + .thenAnswer((_) async => true); + final sharedPrefsStorage = SharedPrefsStorage()..prefs = mockPrefs; + final persistentQueue = await PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.takeAll, + ); + await persistentQueue.save(); + verify( + () => mockPrefs.setString( + SharedPrefsStorage.kPersistentQueue, + any(), + ), + ); + }); + + test( + 'after concurrent calls to save, the saved data should reflect the last instance', + () async { + final persistentQueue = await load(); + persistentQueue.clear(); + unawaited(persistentQueue.save()); + persistentQueue.addAll(_actions); + unawaited(persistentQueue.save()); + persistentQueue.removeLast(); + await persistentQueue.save(); + expect(persistentQueue.length, 1); + expect(persistentQueue.first, _action); + final reconstructed = await load(); + expect(deepEquals(persistentQueue, reconstructed), isTrue); + }, + ); + }); + + group('modify', () { + late SharedPrefsStorage sharedPrefsStorage; + setUp(() { + SharedPreferences.setMockInitialValues( + {SharedPrefsStorage.kPersistentQueue: _nonEmptyJson}, + ); + sharedPrefsStorage = SharedPrefsStorage(); + }); + + Future load() => PersistentQueue.load( + storage: sharedPrefsStorage, + onLoadFilter: DispatchSettings.takeAll, + ); + + // Idea is to use this as generic test by applying the call on a + // PersistentQueue and a reference Queue and compare them for equality + void testCall( + String description, + QueueCall call, [ + AdditionalExpect? additionalExpect, + ]) { + test(description, () async { + final persistentQueue = await load(); + expect(persistentQueue.length, _nonEmptyJsonElementCount); + final reference = Queue.of(persistentQueue); + expect(deepEquals(persistentQueue, reference), isTrue); + final callResult = call(persistentQueue); + final referenceResult = call(reference); + expect(callResult, referenceResult); + expect(deepEquals(persistentQueue, reference), isTrue); + expect(persistentQueue.saveInProgress, isTrue); + await persistentQueue.save(); + expect(persistentQueue.saveInProgress, isFalse); + final reconstructed = await load(); + expect(deepEquals(reconstructed, reference), isTrue); + expect(deepEquals(reconstructed, persistentQueue), isTrue); + if (additionalExpect != null) { + additionalExpect(persistentQueue, reconstructed, reference); + } + }); + } + + testCall( + 'add should modify the queue and call save', + (queue) => queue.add(_action), + (a, b, c) => expect(b.length, _nonEmptyJsonElementCount + 1), + ); + + testCall( + 'addAll should modify the queue and call save', + (queue) => queue.addAll(_actions), + (a, b, c) => + expect(b.length, _nonEmptyJsonElementCount + _actions.length), + ); + + testCall( + 'addFirst should modify the queue and call save', + (queue) => queue.addFirst(_action), + (a, b, c) => expect(mapEquals(b.first, _action), isTrue), + ); + + testCall( + 'addLast should modify the queue and call save', + (queue) => queue.addLast(_action), + (a, b, c) => expect(mapEquals(b.last, _action), isTrue), + ); + + testCall( + 'clear should modify the queue and call save', + (queue) => queue.clear(), + (a, b, c) => expect(b, isEmpty), + ); + + testCall('remove should modify the queue and call save', (queue) { + queue.add(_action); + expect(queue.length, _nonEmptyJsonElementCount + 1); + queue.remove(_action); + expect(queue.length, _nonEmptyJsonElementCount); + }); + + testCall( + 'remove should not modify the queue but call save if the element is not in the list', + (queue) => queue.remove({'not': 'there'}), + (a, b, c) => expect(b.length, _nonEmptyJsonElementCount), + ); + + testCall( + 'removeFirst should modify the queue and call save', + (queue) => queue.removeFirst(), + (a, b, c) => expect(b.first['action2'], 'value2'), + ); + + testCall( + 'removeLast should modify the queue and call save', + (queue) => queue.removeLast(), + (a, b, c) => expect(b.last['action1'], 'value1'), + ); + + testCall( + 'removeWhere should modify the queue and call save', + (queue) => queue.removeWhere(_action1Filter), + (a, b, c) => expect(b.first['action2'], 'value2'), + ); + + testCall( + 'retainWhere should modify the queue and call save', + (queue) => queue.retainWhere(_action1Filter), + (a, b, c) => expect(b.last['action1'], 'value1'), + ); + }); +}