import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; class BrokenService implements AnalyticsService { BrokenService(this.mode); final int mode; @override Future capture(AnalyticsEvent event) { if (mode == 0) throw StateError('offline'); if (mode == 1) return Future.error(StateError('offline')); return Completer().future; } } void main() { test('signup through verification shares ID; retries and duplicates do not inflate outcomes', () { final sink = DummyAnalyticsService(); final recorder = JourneyRecorder(sink); addTearDown(recorder.dispose); final signup = recorder.begin(JourneyKind.signup, JourneyStep.signup); recorder.finish(signup, simulated: true); final send = recorder.begin( JourneyKind.verification, JourneyStep.emailDelivery, onboarding: true, ); recorder.finish(send, simulated: true); final failed = recorder.begin( JourneyKind.verification, JourneyStep.emailConfirmation, onboarding: true, ); recorder.finish(failed, failure: JourneyFailure.expired); final retry = recorder.begin( JourneyKind.verification, JourneyStep.emailConfirmation, onboarding: true, ); recorder.finish(retry, simulated: true, terminal: true); recorder.finish(retry, simulated: true, terminal: true); expect(sink.events.map((e) => e.journeyId).toSet().length, 1); expect(sink.events.where((e) => e.terminal).length, 1); expect(sink.events.last.signal, JourneySignal.simulated); expect(sink.events.last.operation, 4); expect( sink.events.map((e) => e.sequence), orderedEquals(List.generate(9, (i) => i + 1)), ); expect( sink.events.any((e) => e.properties.toString().contains('email_address')), isFalse, ); }); test( 'explicit exit records once and suppresses late operation completion', () { final sink = DummyAnalyticsService(); final recorder = JourneyRecorder(sink); addTearDown(recorder.dispose); final op = recorder.begin(JourneyKind.login, JourneyStep.login); recorder.abandon(JourneyExit.modeChange); recorder.abandon(JourneyExit.navigation); recorder.finish(op, terminal: true); expect(sink.events.last.signal, JourneySignal.abandoned); expect(sink.events.where((e) => e.terminal).length, 1); final newOp = recorder.begin(JourneyKind.login, JourneyStep.login); recorder.finish(newOp, terminal: true); expect(sink.events.map((e) => e.journeyId).toSet().length, 2); }, ); test( 'inactivity waits for pending operations and starts after settled failure', () async { final sink = DummyAnalyticsService(); final recorder = JourneyRecorder( sink, idleWindow: const Duration(milliseconds: 10), ); addTearDown(recorder.dispose); final op = recorder.begin(JourneyKind.login, JourneyStep.login); await Future.delayed(const Duration(milliseconds: 25)); expect(sink.events.any((e) => e.terminal), isFalse); recorder.finish(op, failure: JourneyFailure.unavailable); await Future.delayed(const Duration(milliseconds: 25)); expect(sink.events.last.exit, JourneyExit.inactivity); }, ); for (final mode in [0, 1, 2]) { test('analytics failure mode $mode never blocks outcomes', () async { final recorder = JourneyRecorder(BrokenService(mode)); final op = recorder.begin(JourneyKind.login, JourneyStep.login); recorder.finish(op, terminal: true); recorder.dispose(); await Future.delayed(Duration.zero); }); } test( 'dummy is bounded, immutable and deduplicates repeated event IDs', () async { final sink = DummyAnalyticsService(capacity: 2); final recorder = JourneyRecorder(sink); addTearDown(recorder.dispose); final op = recorder.begin(JourneyKind.login, JourneyStep.login); final event = sink.events.last; await sink.capture(event); expect(sink.events.length, 2); recorder.finish(op, terminal: true); expect(sink.events.length, 2); expect(() => sink.events.clear(), throwsUnsupportedError); expect(() => event.properties['password'] = 'no', throwsUnsupportedError); }, ); }