/// Tests for the interaction-only analytics buffer and Firebase flush. @TestOn('vm') library; import 'dart:convert'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:shared_preferences/shared_preferences.dart'; import 'package:todo/analytics/analytics_event.dart'; import 'package:todo/analytics/analytics_service.dart'; FirebaseRestClient _fakeClient( Future Function(http.Request request) handler, ) => FirebaseRestClient( databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app', auth: FirebaseTokenProvider( apiKey: 'AIzaKey', store: InMemoryCredentialStore( FirebaseCredentials( idToken: 'id', refreshToken: 'refresh', expiresAt: DateTime.now().add(const Duration(hours: 1)), ), ), ), httpClient: http_testing.MockClient(handler), ); void main() { setUp(() => SharedPreferences.setMockInitialValues({})); group('AnalyticsEvent', () { test('round-trips through toJson/tryFromJson', () { final event = AnalyticsEvent( name: 'screen_view', timestamp: DateTime(2026, 1, 1, 12), params: {'screen': 'capture'}, ); final decoded = AnalyticsEvent.tryFromJson(event.toJson()); expect(decoded, isNotNull); expect(decoded!.name, 'screen_view'); expect(decoded.timestamp, event.timestamp); expect(decoded.params, {'screen': 'capture'}); }); test('tryFromJson returns null for a non-map value', () { expect(AnalyticsEvent.tryFromJson('not a map'), isNull); expect(AnalyticsEvent.tryFromJson(null), isNull); }); test('tryFromJson returns null for a missing/invalid name', () { expect( AnalyticsEvent.tryFromJson({'timestamp': '2026-01-01T00:00:00.000'}), isNull, ); }); test('tryFromJson returns null for an unparsable timestamp', () { expect( AnalyticsEvent.tryFromJson({'name': 'x', 'timestamp': 'not a date'}), isNull, ); }); test('tryFromJson defaults params to empty when missing/non-map', () { final decoded = AnalyticsEvent.tryFromJson({ 'name': 'x', 'timestamp': DateTime(2026).toIso8601String(), }); expect(decoded!.params, isEmpty); }); }); group('AnalyticsService.logEvent', () { test('appends to an initially empty buffer', () async { const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()), ); final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('analytics.buffer'); expect(raw, isNotNull); final decoded = jsonDecode(raw!) as List; expect(decoded, hasLength(1)); }); test('drops the oldest event once past the cap', () async { const service = AnalyticsService(nodeId: 'device-a'); for (var i = 0; i < AnalyticsService.maxBufferedEvents + 5; i++) { await service.logEvent( AnalyticsEvent( name: 'event_$i', timestamp: DateTime.now(), ), ); } final prefs = await SharedPreferences.getInstance(); final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List; expect(decoded, hasLength(AnalyticsService.maxBufferedEvents)); // The oldest 5 events were dropped, so event_0..event_4 are gone. final names = decoded.map((e) => (e as Map)['name']).toList(); expect(names, isNot(contains('event_0'))); expect( names, contains('event_${AnalyticsService.maxBufferedEvents + 4}'), ); }); test('ignores a malformed pre-existing buffer entry on read', () async { SharedPreferences.setMockInitialValues({ 'analytics.buffer': jsonEncode([ {'not': 'an event'}, { 'name': 'ok_event', 'timestamp': DateTime(2026).toIso8601String(), 'params': {}, }, ]), }); const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'new_event', timestamp: DateTime.now()), ); final prefs = await SharedPreferences.getInstance(); final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List; // The malformed entry was dropped; only the valid + new event remain. expect(decoded, hasLength(2)); }); }); group('AnalyticsService.flush', () { test('does nothing when client is null', () async { const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()), ); await service.flush(null); final prefs = await SharedPreferences.getInstance(); expect(prefs.getString('analytics.buffer'), isNotNull); }); test('does nothing when the buffer is empty', () async { final requests = []; final client = _fakeClient((request) async { requests.add(request.method); return http.Response('{}', 200); }); const service = AnalyticsService(nodeId: 'device-a'); await service.flush(client); expect(requests, isEmpty); }); test('pushes the buffer as one batch and clears it on success', () async { String? putPath; String? putBody; final client = _fakeClient((request) async { if (request.method == 'PUT') { putPath = request.url.path; putBody = request.body; } return http.Response(request.body, 200); }); const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)), ); await service.logEvent( AnalyticsEvent(name: 'note_created', timestamp: DateTime(2026)), ); await service.flush(client); expect(putPath, startsWith('/analytics/device-a/')); final decodedBody = jsonDecode(jsonDecode(putBody!) as String) as List; expect(decodedBody, hasLength(2)); final prefs = await SharedPreferences.getInstance(); expect(prefs.getString('analytics.buffer'), isNull); }); test('leaves the buffer untouched on a push failure', () async { final client = _fakeClient((_) async => http.Response('error', 500)); const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()), ); await service.flush(client); final prefs = await SharedPreferences.getInstance(); final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List; expect(decoded, hasLength(1)); }); test('keeps an event logged after the batch was already claimed', () async { // The HTTP handler runs mid-`putFileText`, strictly after flush() has // already claimed (cleared) the buffer — so writing here lands in a // buffer this flush() call no longer owns, unlike writing to prefs // before calling flush() (which would just become part of the claimed // batch itself). final client = _fakeClient((request) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString( 'analytics.buffer', jsonEncode([ AnalyticsEvent( name: 'mid_flush_event', timestamp: DateTime.now(), ).toJson(), ]), ); return http.Response(request.body, 200); }); const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()), ); await service.flush(client); final prefs = await SharedPreferences.getInstance(); final remaining = jsonDecode(prefs.getString('analytics.buffer')!) as List; expect(remaining, hasLength(1)); expect((remaining.single as Map)['name'], 'mid_flush_event'); }); test( 'restores the claimed batch ahead of events logged mid-flush on ' 'failure', () async { // The HTTP handler runs mid-`putFileText`, strictly after flush() // has already claimed (cleared) the buffer, so writing here is a // genuine race rather than something that could instead land before // or after `flush()` depending on scheduling. final client = _fakeClient((_) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString( 'analytics.buffer', jsonEncode([ AnalyticsEvent( name: 'mid_flush_event', timestamp: DateTime(2026), ).toJson(), ]), ); return http.Response('error', 500); }); const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)), ); await service.flush(client); final prefs = await SharedPreferences.getInstance(); final remaining = jsonDecode(prefs.getString('analytics.buffer')!) as List; expect(remaining, hasLength(2)); expect((remaining[0] as Map)['name'], 'app_open'); expect((remaining[1] as Map)['name'], 'mid_flush_event'); }, ); test( 'a RemoteSyncError from an expired session restores the buffer', () async { final client = FirebaseRestClient( databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app', auth: FirebaseTokenProvider( apiKey: 'AIzaKey', store: InMemoryCredentialStore(null), ), ); const service = AnalyticsService(nodeId: 'device-a'); await service.logEvent( AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)), ); await service.flush(client); final prefs = await SharedPreferences.getInstance(); final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List; expect(decoded, hasLength(1)); }, ); }); }