// Regression tests for the onboarding recall flows: restoring a seed and // recalling the registered address must land the user in the inbox. The // client's network operations are stubbed; what is under test is the UI and // router flow itself. import "dart:io"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; import "package:smol_mail/smol/client.dart"; import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; /// A [SmolClient] whose recall completes instantly, so the test exercises /// the flow rather than the network. class StubClient extends SmolClient { StubClient(super.store); @override Future recallAccount(String addressText) async { final addr = parseAddress(addressText); store.setAccount(addr); return addr; } } void main() { // Hive box opening is real file IO and must happen outside testWidgets' // fake-async zone — including the per-test stores. late final SmolStore storeA, storeB, storeC; setUpAll(() async { TestWidgetsFlutterBinding.ensureInitialized(); final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); Hive.init(dir.path); storeA = await SmolStore.open( stateBox: "recall-a-state", mailBox: "recall-a-mail"); storeB = await SmolStore.open( stateBox: "recall-b-state", mailBox: "recall-b-mail"); storeC = await SmolStore.open( stateBox: "recall-c-state", mailBox: "recall-c-mail"); }); Widget app(SmolStore store) => ProviderScope( overrides: [ storeProvider.overrideWithValue(store), clientProvider.overrideWithValue(StubClient(store)), ], child: const AppWidget(), ); testWidgets( "restore with an address, but no pin yet, asks for the server key " "and then recalls into the inbox", (tester) async { final store = storeA; final seed = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); var fields = find.byType(TextField); expect(fields, findsNWidgets(2)); await tester.enterText(fields.at(0), hex(seed)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Recall needs the server pinned first (SPEC.md §4) — that's the normal // state right after a restore, so this lands on the register step framed // for recall (no error, no Invite token field) rather than the inbox yet. expect(find.text("Inbox"), findsNothing); expect(find.text("Pin this server's public key to finish restoring your address."), findsOneWidget); expect(find.text("Invite token (optional)"), findsNothing); fields = find.byType(TextField); expect(fields, findsNWidgets(2)); // address (carried over), server key await tester.enterText(fields.at(1), b32encode(randomBytes(32))); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.seed(), seed); expect(store.account()!.user, "randogoth"); }); testWidgets("restore without an address recalls from the register step", (tester) async { final store = storeB; final seed = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); var fields = find.byType(TextField); await tester.enterText(fields.at(0), hex(seed)); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // The register step: address, server key, optional invite token. fields = find.byType(TextField); expect(fields, findsNWidgets(3)); await tester.enterText(fields.at(0), "randogoth@smol.place"); await tester.enterText(fields.at(1), b32encode(randomBytes(32))); await tester.tap(find.text("Already registered? Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.account()!.user, "randogoth"); }); testWidgets( "restoring again after an incomplete attempt replaces the identity " "instead of refusing it", (tester) async { final store = storeC; final abandonedSeed = randomBytes(32); final realSeed = randomBytes(32); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); // First attempt: restore a seed but never finish registering — lands on // the register step, identity set, no account bound. await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); var fields = find.byType(TextField); await tester.enterText(fields.at(0), hex(abandonedSeed)); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); expect(store.seed(), abandonedSeed); expect(store.account(), isNull); // Simulate returning to onboarding later (e.g. a cold restart). Pumping // app(store) directly would just rebuild the existing OnboardingScreen // state in place (still parked on the register step) rather than really // restarting, so tear the tree down first to force a fresh app state — // HomeGuard then sends an identity-without-account back to welcome. await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(app(store)); await tester.pumpAndSettle(); expect(find.text("Create Identity"), findsOneWidget); // Restoring a different seed must not throw "identity already exists". await tester.tap(find.text("Restore From Seed")); await tester.pumpAndSettle(); fields = find.byType(TextField); await tester.enterText(fields.at(0), hex(realSeed)); await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.tap(find.text("Restore")); await tester.pumpAndSettle(); // Lands on the recall-framed register step (no pin yet); pin it and finish. fields = find.byType(TextField); expect(fields, findsNWidgets(2)); await tester.enterText(fields.at(1), b32encode(randomBytes(32))); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.seed(), realSeed); }); }