PIN sign-in, because the code could never arrive
── What was actually broken ── The SMS gateway was switched off, and `POST /auth/otp/request` does not fail when that happens: it still answers `sent: true`, still issues a valid 4-digit code, and writes it to the **server log**. So the phone path walked customers to a code screen for a code that could not arrive, and every digit they eventually typed was wrong. The failure read to them as "I entered it wrong". Phone sign-in is now a PIN, which needs no gateway. ── Email still sends codes, so email is untouched ── Email OTP goes over SMTP and works. Deleting a working way in to tidy up a broken one is a net loss for anyone with an email on their account, so "Use email instead" and the code screen stay exactly as they were. `login_otp_guard_test` moves to that path — the `sent: false` guard still matters there, and that is now the only place it can fire. ── One screen, three entrances ── `POST /auth/login` says which of them a number is before anything is asked, so the app never guesses. Guessing is not cosmetic: offer "create a PIN" to a returning customer and the server answers `pin_already_set` on a screen that cannot succeed; offer "enter your PIN" to somebody who has never set one and every attempt is wrong. The separate sign-up screen is deleted rather than hidden. It asked for a name and then sent an SMS code — a second entrance asking the same questions and posting a letter that never lands. A new number now gives its name and PIN on the same screen. ── A second sign-in path found a latent bug ── `AppState.signIn` only started `refreshOrders`, and the OTP screen called `detectPickupLocation` itself afterwards to make up the difference. That held exactly as long as there was one sign-in screen. PIN sign-in did not know about the extra call, so Home opened with no pickup and no serviceable cities. The work belongs to signing in, not to whichever screen happened to be last, so it moved into `signIn` and the OTP screen's copy is gone. ── What the screen deliberately does not do ── It does not greet by name. `POST /auth/login` returns the account holder's name, which tells anybody who types a number who owns it; the field is read but never displayed, so it disappears quietly when the backend drops it. It does not say whether the number or the PIN was wrong — the server answers identically for both on purpose, and narrowing it here would turn sign-in into a way of testing whether a number has an account. "Forgot your PIN?" renders only when a support contact is configured. There is no reset endpoint, so it can only point at a human — and telling somebody locked out that help exists without saying where is worse than silence. ── The handover note does not reach the Miler ── The app said "we pass this to your Miler as a note". It does not: `remarks` reaches the admin console and stops, because the rider app reads a `notes` field per stop that the backend never sends. A customer could hand their parcel to a neighbour believing the Miler had been told. Both screens now say it is recorded on the booking, and that the Miler still calls the account's number. ── Also ── DmTextField gains `obscure`, and PinScreen carries a back button — without one the only correction for a mistyped digit was killing the app.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:doormile_cx/ui/widgets/inputs.dart';
|
||||
import 'package:doormile_cx/data/doormile_api.dart';
|
||||
import 'package:doormile_cx/data/location_service.dart';
|
||||
import 'package:doormile_cx/data/models.dart';
|
||||
@@ -54,11 +55,16 @@ Future<void> signIn(WidgetTester tester, {AppState? state}) async {
|
||||
await tester.tap(find.text('Continue'));
|
||||
await settle(tester);
|
||||
|
||||
// One field, not four boxes, and it verifies as soon as the fourth digit
|
||||
// lands. Entered in one go on purpose: four separate fields dropped a digit
|
||||
// whenever a keystroke arrived while focus was moving between them, which is
|
||||
// what typing at any normal speed does.
|
||||
await tester.enterText(find.byType(TextField).first, '1111');
|
||||
// ── A PIN now, not a posted code ──
|
||||
//
|
||||
// The SMS gateway is off: `otp/request` still answers `sent: true` and
|
||||
// writes the code to the server log, so the old four-box screen could never
|
||||
// be completed by a customer. 9876543210 is a number the fake backend knows
|
||||
// with a PIN already set, so this walks the returning-customer path and the
|
||||
// button submits rather than the field auto-verifying.
|
||||
await tester.enterText(find.byType(TextField).first, '1234');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
@@ -172,37 +178,84 @@ void main() {
|
||||
|
||||
tearDown(() => DoormileApi.overrideInstance(null));
|
||||
|
||||
testWidgets('the code is one field, typed a digit at a time',
|
||||
testWidgets('a phone number is asked about before a PIN is asked for',
|
||||
(tester) async {
|
||||
// The regression this locks: the code used to be four `TextField`s that
|
||||
// passed focus along, and a keystroke arriving mid-transition was dropped.
|
||||
// The customer typed four digits, three arrived, and the screen answered
|
||||
// "That code did not match" — blaming them for its own race.
|
||||
// ── The regression this locks ──
|
||||
//
|
||||
// PIN sign-in has three entrances that look identical to a customer: an
|
||||
// unknown number, a known number with no PIN, and a known number with one.
|
||||
// The app asks `POST /auth/login` which it is, and renders that screen.
|
||||
//
|
||||
// Guessing is not cosmetic. Offer "create a PIN" to a returning customer
|
||||
// and the server answers `pin_already_set` on a screen that cannot
|
||||
// succeed; offer "enter your PIN" to somebody who has never set one and
|
||||
// every attempt they make is wrong.
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(const DoormileApp());
|
||||
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
|
||||
// the entrance is waited out once, here, rather than by lengthening every
|
||||
// settle in the suite.
|
||||
await settle(tester, 4400);
|
||||
|
||||
// A number the fake backend knows, with a PIN already set.
|
||||
await tester.enterText(find.byType(TextField).first, '9876543210');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Continue'));
|
||||
await settle(tester);
|
||||
|
||||
// One field. Nothing to pass focus between, so nothing to drop it in.
|
||||
expect(find.text('Enter your PIN'), findsOneWidget);
|
||||
// One field, and it is masked — a PIN is a credential the customer keeps,
|
||||
// unlike a posted code they use once.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<DmTextField>(find.byType(DmTextField)).obscure,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final field = find.byType(TextField).first;
|
||||
for (final sofar in ['1', '12', '123', '1234']) {
|
||||
await tester.enterText(field, sofar);
|
||||
await tester.pump();
|
||||
}
|
||||
// A wrong PIN says so without saying which of the two was wrong.
|
||||
await tester.enterText(find.byType(TextField).first, '9999');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
expect(find.text('That number or PIN is incorrect'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsNothing);
|
||||
|
||||
// The right one lands on Home.
|
||||
await tester.enterText(find.byType(TextField).first, '1234');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Sign in'));
|
||||
await settle(tester);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('a number with no account asks for a name and a new PIN',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1230, 9000);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(const DoormileApp());
|
||||
await settle(tester, 4400);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '9111122223');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Continue'));
|
||||
await settle(tester);
|
||||
|
||||
// Name, PIN, confirm — the sign-up screen's questions, without a second
|
||||
// screen and without an SMS that cannot arrive.
|
||||
expect(find.text('Create your account'), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNWidgets(3));
|
||||
|
||||
final fields = find.byType(TextField);
|
||||
await tester.enterText(fields.at(0), 'Meera S');
|
||||
await tester.enterText(fields.at(1), '4821');
|
||||
await tester.enterText(fields.at(2), '4821');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Create PIN and continue'));
|
||||
await settle(tester);
|
||||
|
||||
// All four digits arrived, so it verified and moved on.
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
@@ -819,7 +872,18 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows the network error state with retry', (tester) async {
|
||||
await signIn(tester);
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
// ── The cache has to be cold for the error to be reachable ──
|
||||
//
|
||||
// Signing in now warms the serviceable cities, and the destination sheet
|
||||
// renders `cachedCities` as its `initialItems` rather than a skeleton — so
|
||||
// with a warm cache and no network it shows the list it already has, which
|
||||
// is the right behaviour and not what this test is about. Clearing the
|
||||
// caches puts it back in the state a first-ever open is in.
|
||||
state.statesCache = null;
|
||||
state.districtCache.clear();
|
||||
api.flags.networkError = true;
|
||||
|
||||
await openSend(tester);
|
||||
@@ -895,7 +959,8 @@ void main() {
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('sign up survives large accessibility text', (tester) async {
|
||||
testWidgets('creating an account survives large accessibility text',
|
||||
(tester) async {
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
@@ -904,11 +969,17 @@ void main() {
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(const DoormileApp());
|
||||
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
|
||||
// the entrance is waited out once, here, rather than by lengthening every
|
||||
// settle in the suite.
|
||||
await settle(tester, 4400);
|
||||
await tester.tap(find.text('Create account'));
|
||||
|
||||
// ── There is no separate sign-up screen any more ──
|
||||
//
|
||||
// It asked for a name and then sent an SMS code, which cannot arrive with
|
||||
// the gateway off. A new number is recognised by `POST /auth/login` and
|
||||
// the PIN screen asks for the name and the PIN together, so this walks the
|
||||
// real entrance instead of a "Create account" link that no longer exists.
|
||||
await tester.enterText(find.byType(TextField).first, '9111122223');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Continue'));
|
||||
await settle(tester);
|
||||
|
||||
// Building it is the assertion: any overflow in the hero or the form
|
||||
|
||||
Reference in New Issue
Block a user