Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
194 lines
6.8 KiB
Dart
194 lines
6.8 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||
|
||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||
|
||
import 'package:sxchnge_app/main.dart';
|
||
import 'package:sxchnge_app/routing/routes.dart';
|
||
|
||
Future<void> open(
|
||
WidgetTester tester,
|
||
String path,
|
||
DummyAnalyticsService sink,
|
||
) async {
|
||
tester.platformDispatcher.defaultRouteNameTestValue = path;
|
||
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||
await tester.pumpWidget(
|
||
SxchngeApp(
|
||
analyticsService: sink,
|
||
signupService: const DummySignupService(delay: Duration.zero),
|
||
verificationService: const DummyVerificationService(delay: Duration.zero),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
Future<void> tap(WidgetTester tester, Finder finder) async {
|
||
await tester.ensureVisible(finder);
|
||
await tester.tap(finder);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
void main() {
|
||
testWidgets(
|
||
'recipient change removes sent claim, focuses editor and requires a new send',
|
||
(tester) async {
|
||
final sink = DummyAnalyticsService();
|
||
await open(tester, '/verification-sent', sink);
|
||
final email = find.byKey(const ValueKey('verification-email'));
|
||
await tester.enterText(email, 'alex@example.com');
|
||
await tap(tester, find.byKey(const ValueKey('verification-resend')));
|
||
expect(email, findsNothing);
|
||
expect(
|
||
find.byKey(const ValueKey('verification-recipient')),
|
||
findsOneWidget,
|
||
);
|
||
await tap(
|
||
tester,
|
||
find.byKey(const ValueKey('verification-change-email')),
|
||
);
|
||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||
final editable = tester.widget<EditableText>(
|
||
find.descendant(of: email, matching: find.byType(EditableText)),
|
||
);
|
||
expect(editable.focusNode.hasFocus, isTrue);
|
||
await tester.enterText(email, 'different@example.com');
|
||
expect(
|
||
find.byKey(const ValueKey('verification-recipient')),
|
||
findsNothing,
|
||
);
|
||
await tester.testTextInput.receiveAction(TextInputAction.send);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('different@example.com'), findsOneWidget);
|
||
expect(
|
||
sink.events
|
||
.where(
|
||
(e) =>
|
||
e.step == JourneyStep.emailDelivery &&
|
||
e.signal == JourneySignal.requested,
|
||
)
|
||
.length,
|
||
2,
|
||
);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'confirmation error has primary retry and keyboard-accessible new-email recovery',
|
||
(tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
final sink = DummyAnalyticsService();
|
||
await open(tester, '/verify-email?token=demo-internal', sink);
|
||
expect(
|
||
tester.widget(find.byKey(const ValueKey('verification-retry'))),
|
||
isA<FilledButton>(),
|
||
);
|
||
expect(find.byKey(const ValueKey('verification-email')), findsNothing);
|
||
final node = tester.getSemantics(
|
||
find.byKey(const ValueKey('verification-error')),
|
||
);
|
||
expect(node.getSemanticsData().flagsCollection.isHeader, isTrue);
|
||
expect(node.getSemanticsData().flagsCollection.isLiveRegion, isTrue);
|
||
await tap(tester, find.text('Send a new verification email'));
|
||
await tester.enterText(
|
||
find.byKey(const ValueKey('verification-email')),
|
||
'alex@example.com',
|
||
);
|
||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||
await tester.pump();
|
||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||
await tester.pumpAndSettle();
|
||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||
semantics.dispose();
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'signup to demo verification is correlated and never claims a business success',
|
||
(tester) async {
|
||
final sink = DummyAnalyticsService();
|
||
await open(tester, '/', sink);
|
||
await tap(tester, find.text('Sign up'));
|
||
await tester.enterText(find.byKey(const ValueKey('name')), 'Alex');
|
||
await tester.enterText(
|
||
find.byKey(const ValueKey('email')),
|
||
'alex@example.com',
|
||
);
|
||
await tester.enterText(
|
||
find.byKey(const ValueKey('password')),
|
||
'example-password',
|
||
);
|
||
await tester.enterText(
|
||
find.byKey(const ValueKey('confirmation')),
|
||
'example-password',
|
||
);
|
||
await tap(tester, find.byKey(const ValueKey('submit')));
|
||
expect(find.text('Demo email prepared.'), findsOneWidget);
|
||
expect(find.textContaining('We’ve sent you'), findsNothing);
|
||
await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
|
||
expect(find.text('Demo verification\ncomplete.'), findsOneWidget);
|
||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||
expect(
|
||
sink.events.where((e) => e.signal == JourneySignal.succeeded),
|
||
isEmpty,
|
||
);
|
||
expect(sink.events.every((e) => e.simulated), isTrue);
|
||
final payload = sink.events.map((e) => e.properties).toString();
|
||
for (final secret in [
|
||
'alex@example.com',
|
||
'example-password',
|
||
'Alex',
|
||
'demo-valid',
|
||
]) {
|
||
expect(payload, isNot(contains(secret)));
|
||
}
|
||
final count = sink.events.length;
|
||
await tester.pumpAndSettle();
|
||
expect(sink.events.length, count);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'exit from a deep-linked failed confirmation records abandonment once',
|
||
(tester) async {
|
||
final sink = DummyAnalyticsService();
|
||
await open(tester, '/verify-email?token=demo-expired', sink);
|
||
await tap(tester, find.text('Back to login'));
|
||
expect(sink.events.last.signal, JourneySignal.abandoned);
|
||
expect(sink.events.last.exit, JourneyExit.navigation);
|
||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
|
||
router.goNamed(AppRoutes.login);
|
||
await tester.pumpAndSettle();
|
||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'verification controls have accessible labels and mobile tap targets',
|
||
(tester) async {
|
||
final semantics = tester.ensureSemantics();
|
||
tester.view.physicalSize = const Size(390, 844);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.resetPhysicalSize);
|
||
addTearDown(tester.view.resetDevicePixelRatio);
|
||
await open(
|
||
tester,
|
||
'/verify-email?token=demo-internal',
|
||
DummyAnalyticsService(),
|
||
);
|
||
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
|
||
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
||
semantics.dispose();
|
||
},
|
||
);
|
||
}
|