Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
387 lines
14 KiB
Dart
387 lines
14 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:http/testing.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'package:miler/data/api_config.dart';
|
||
import 'package:miler/data/miler_api.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// SELF-SET PIN ON FIRST SIGN-IN
|
||
///
|
||
/// The console no longer issues a rider a PIN. `POST /miler/login` answers
|
||
/// `pin_set`, and that one boolean decides which screen he is owed:
|
||
///
|
||
/// pin_set: false → Set-PIN → POST /miler/set-pin → logged in
|
||
/// pin_set: true → Enter-PIN → POST /miler/verify-pin → logged in
|
||
///
|
||
/// ── What this replaces, and why it was a dead end ──
|
||
///
|
||
/// The app read only the **status code** of `/miler/login` and threw the body
|
||
/// away. From "an account exists" it inferred Enter-PIN; from "it does not" it
|
||
/// inferred an OTP branch that verified nothing — `verifyOtp` returned `true`
|
||
/// without checking a digit — and ended at a Create-MPIN screen whose save
|
||
/// button called `updatePin`, which had no route behind it and returned a
|
||
/// manufactured `403 "Your MPIN is issued by your office and cannot be changed
|
||
/// from the app."`
|
||
///
|
||
/// So a rider with no PIN could not sign in by any path, and the three seeded
|
||
/// test riders (`8000000001`–`3`) were unreachable.
|
||
///
|
||
/// ── The rule these tests exist to hold ──
|
||
///
|
||
/// **Branch on the boolean, never on the message.** `"PIN verification
|
||
/// required"` is prose and prose gets reworded; the day it does, a
|
||
/// string-matching client sends every rider to the wrong screen.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
late List<http.Request> sent;
|
||
|
||
/// Installs a fake server. [handler] answers by path.
|
||
void serve(http.Response Function(http.Request req) handler) {
|
||
sent = <http.Request>[];
|
||
MilerApi.client = MockClient((req) async {
|
||
sent.add(req);
|
||
return handler(req);
|
||
});
|
||
}
|
||
|
||
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||
tearDown(() => MilerApi.client = http.Client());
|
||
|
||
Map<String, dynamic> bodyOf(http.Request r) =>
|
||
jsonDecode(r.body) as Map<String, dynamic>;
|
||
|
||
/// The session envelope `set-pin` and `verify-pin` both answer with.
|
||
String sessionBody({int userId = 46}) => jsonEncode({
|
||
'success': true,
|
||
'token': 'jwt-for-$userId',
|
||
'tenantid': 1,
|
||
'tenantname': 'Doormile Coimbatore Logistics',
|
||
'user': {
|
||
'userid': userId,
|
||
'authname': 'Seed Rider',
|
||
'contactno': '8000000001',
|
||
'profile': {
|
||
'userid': userId,
|
||
'displayname': 'Seed Rider',
|
||
'phone': '8000000001',
|
||
'availabilitystatus': 'Offline',
|
||
},
|
||
},
|
||
});
|
||
|
||
group('pin_set decides the screen', () {
|
||
test('pin_set:false → the rider must set one', () async {
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({
|
||
'success': true,
|
||
'message': 'PIN verification required',
|
||
'phone': '8000000001',
|
||
'pin_set': false,
|
||
}),
|
||
200,
|
||
),
|
||
);
|
||
|
||
final res = await MilerApi.login('8000000001');
|
||
expect(res.ok, isTrue);
|
||
expect(MilerApi.pinSetOf(res), isFalse);
|
||
});
|
||
|
||
test('pin_set:true → the existing Enter-PIN path', () async {
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({
|
||
'success': true,
|
||
'message': 'PIN verification required',
|
||
'phone': '9000000009',
|
||
'pin_set': true,
|
||
}),
|
||
200,
|
||
),
|
||
);
|
||
|
||
final res = await MilerApi.login('9000000009');
|
||
expect(MilerApi.pinSetOf(res), isTrue);
|
||
});
|
||
|
||
test('the MESSAGE is identical in both cases — only the flag differs', () {
|
||
// The reason this is a boolean and not a string match. Both responses
|
||
// carry "PIN verification required"; a client reading the sentence cannot
|
||
// tell a first-time rider from a returning one.
|
||
const first = '{"success":true,"message":"PIN verification required",'
|
||
'"pin_set":false}';
|
||
const returning = '{"success":true,"message":"PIN verification required",'
|
||
'"pin_set":true}';
|
||
expect(
|
||
jsonDecode(first)['message'],
|
||
jsonDecode(returning)['message'],
|
||
);
|
||
expect(
|
||
jsonDecode(first)['pin_set'],
|
||
isNot(jsonDecode(returning)['pin_set']),
|
||
);
|
||
});
|
||
|
||
test('an absent pin_set is null, and callers fall back to Enter-PIN', () async {
|
||
// An older server, or a body that did not carry the field. Enter-PIN is
|
||
// the safe direction: a rider who HAS a PIN can sign in, and one who does
|
||
// not gets a refusal he can report — rather than a Set-PIN screen that
|
||
// will 409 and strand him.
|
||
serve((_) => http.Response(jsonEncode({'success': true}), 200));
|
||
final res = await MilerApi.login('8000000001');
|
||
expect(MilerApi.pinSetOf(res), isNull);
|
||
});
|
||
|
||
test('a string "false" is read as false, not as truthy prose', () async {
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({'success': true, 'pin_set': 'false'}),
|
||
200,
|
||
),
|
||
);
|
||
expect(MilerApi.pinSetOf(await MilerApi.login('8000000001')), isFalse);
|
||
});
|
||
});
|
||
|
||
group('POST /miler/set-pin', () {
|
||
test('sends the contract body and stores the session', () async {
|
||
serve((req) => http.Response(sessionBody(), 200));
|
||
|
||
final res = await MilerApi.setPin(
|
||
phone: '8000000001',
|
||
pin: '4271',
|
||
deviceToken: 'fcm-token-abc',
|
||
);
|
||
|
||
expect(res.ok, isTrue);
|
||
expect(sent.single.url.path, endsWith('/miler/set-pin'));
|
||
|
||
final body = bodyOf(sent.single);
|
||
expect(body['phone'], '8000000001');
|
||
expect(body['new_pin'], '4271');
|
||
expect(body['configid'], MilerApi.configId);
|
||
expect(body['device_token'], 'fcm-token-abc');
|
||
expect(
|
||
body.containsKey('pin'),
|
||
isFalse,
|
||
reason: 'the field is new_pin on this route, not pin',
|
||
);
|
||
|
||
// The whole point: the rider is signed in by this one call.
|
||
expect(await ApiConfig.getToken(), 'jwt-for-46');
|
||
});
|
||
|
||
test('the PIN goes as a STRING, so a leading zero survives', () async {
|
||
// The backend bcrypt-compares the PIN as text. Round-tripping "0512"
|
||
// through an int gives 512, and a valid PIN is refused forever.
|
||
serve((req) => http.Response(sessionBody(), 200));
|
||
await MilerApi.setPin(phone: '8000000001', pin: '0512');
|
||
expect(bodyOf(sent.single)['new_pin'], '0512');
|
||
});
|
||
|
||
test('device_token is omitted rather than sent empty', () async {
|
||
serve((req) => http.Response(sessionBody(), 200));
|
||
await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||
expect(bodyOf(sent.single).containsKey('device_token'), isFalse);
|
||
});
|
||
|
||
test('409 — a PIN already exists, and no session is minted', () async {
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({
|
||
'success': false,
|
||
'message': 'PIN already set for this account',
|
||
}),
|
||
409,
|
||
),
|
||
);
|
||
|
||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||
expect(res.ok, isFalse);
|
||
expect(res.status, 409);
|
||
expect(
|
||
await ApiConfig.getToken(),
|
||
isNull,
|
||
reason: 'a refused set-pin must never leave a token behind',
|
||
);
|
||
});
|
||
|
||
test('404 — the number is not registered', () async {
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({'success': false, 'message': 'no miler account'}),
|
||
404,
|
||
),
|
||
);
|
||
final res = await MilerApi.setPin(phone: '7000000000', pin: '4271');
|
||
expect(res.status, 404);
|
||
expect(res.ok, isFalse);
|
||
});
|
||
|
||
test('403 — the account is not an active miler', () async {
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({'success': false, 'message': 'inactive'}),
|
||
403,
|
||
),
|
||
);
|
||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||
expect(res.status, 403);
|
||
expect(res.ok, isFalse);
|
||
});
|
||
|
||
test('a 200 with success:false is still a failure', () async {
|
||
// The envelope rule: the status line alone is not the answer.
|
||
serve(
|
||
(_) => http.Response(
|
||
jsonEncode({'success': false, 'message': 'nope'}),
|
||
200,
|
||
),
|
||
);
|
||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||
expect(res.ok, isFalse);
|
||
expect(await ApiConfig.getToken(), isNull);
|
||
});
|
||
|
||
test('a success carrying no token leaves no session', () async {
|
||
// The credentials were accepted and there is nothing to sign in with.
|
||
// An integration fault — and it must not read as a stored session.
|
||
serve(
|
||
(_) => http.Response(jsonEncode({'success': true}), 200),
|
||
);
|
||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||
expect(res.ok, isTrue);
|
||
expect(await ApiConfig.getToken(), isNull);
|
||
});
|
||
});
|
||
|
||
group('the returning rider is untouched', () {
|
||
test('verify-pin still sends `pin` to its own route', () async {
|
||
serve((req) => http.Response(sessionBody(userId: 38), 200));
|
||
|
||
await MilerApi.verifyPin(phone: '9000000009', pin: '1234');
|
||
|
||
expect(sent.single.url.path, endsWith('/miler/verify-pin'));
|
||
final body = bodyOf(sent.single);
|
||
expect(body['pin'], '1234');
|
||
expect(
|
||
body.containsKey('new_pin'),
|
||
isFalse,
|
||
reason: 'new_pin belongs to set-pin alone',
|
||
);
|
||
expect(await ApiConfig.getToken(), 'jwt-for-38');
|
||
});
|
||
|
||
test('the two routes are genuinely different endpoints', () async {
|
||
serve((req) => http.Response(sessionBody(), 200));
|
||
await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||
await MilerApi.verifyPin(phone: '8000000001', pin: '4271');
|
||
|
||
expect(sent, hasLength(2));
|
||
expect(sent[0].url.path, endsWith('/miler/set-pin'));
|
||
expect(sent[1].url.path, endsWith('/miler/verify-pin'));
|
||
});
|
||
});
|
||
|
||
group('the seeded riders', () {
|
||
// The three created with no PIN, for exactly this flow. Before this change
|
||
// none of them could sign in: precheck read "account exists" and sent them
|
||
// to Enter-PIN, where no PIN they typed would ever match.
|
||
for (final phone in const ['8000000001', '8000000002', '8000000003']) {
|
||
test('$phone is routed to Set-PIN and signs in', () async {
|
||
serve((req) {
|
||
if (req.url.path.endsWith('/miler/login')) {
|
||
return http.Response(
|
||
jsonEncode({
|
||
'success': true,
|
||
'message': 'PIN verification required',
|
||
'phone': phone,
|
||
'pin_set': false,
|
||
}),
|
||
200,
|
||
);
|
||
}
|
||
return http.Response(sessionBody(), 200);
|
||
});
|
||
|
||
final login = await MilerApi.login(phone);
|
||
expect(
|
||
MilerApi.pinSetOf(login),
|
||
isFalse,
|
||
reason: 'seeded riders are created without a PIN',
|
||
);
|
||
|
||
final set = await MilerApi.setPin(phone: phone, pin: '4271');
|
||
expect(set.ok, isTrue);
|
||
expect(await ApiConfig.getToken(), isNotNull);
|
||
|
||
expect(sent, hasLength(2));
|
||
expect(sent[0].url.path, endsWith('/miler/login'));
|
||
expect(sent[1].url.path, endsWith('/miler/set-pin'));
|
||
});
|
||
}
|
||
});
|
||
|
||
group('no master PIN and no dead OTP path survive', () {
|
||
/// Source with every comment line stripped.
|
||
///
|
||
/// These assertions are about **code**, not prose — and the distinction is
|
||
/// not academic: the comments that explain why the master-PIN constants
|
||
/// were removed necessarily quote their names, and a naive scan matches its
|
||
/// own explanation and fails. Strip the commentary, assert the code.
|
||
String codeOf(String path) => File(path)
|
||
.readAsLinesSync()
|
||
.where((l) => !l.trimLeft().startsWith('//'))
|
||
.join('\n');
|
||
|
||
final auth = codeOf('lib/controllers/auth.dart');
|
||
final signIn = codeOf('lib/views/onboardscreens/Sign_in.dart');
|
||
|
||
test('the 1234 master-PIN constants are gone', () {
|
||
// `_masterPinValue = '1234'`, `masterPinValue` and
|
||
// `forceMasterPinPrefKey` were declared on AuthController and read by
|
||
// nothing — leftovers of a removed feature. A public constant named
|
||
// `masterPinValue` holding four digits reads as a back door whether or
|
||
// not anything calls it, and it collided with a PIN a rider could now
|
||
// legitimately choose.
|
||
expect(auth, isNot(contains('masterPinValue')));
|
||
expect(auth, isNot(contains('forceMasterPinPrefKey')));
|
||
expect(auth, isNot(contains('_forceMasterPinFlow')));
|
||
});
|
||
|
||
test('the office-issues-your-PIN dead end is gone', () {
|
||
// The sentence the Create-MPIN screen used to end at, because there was
|
||
// no route behind its save button. There is one now.
|
||
expect(
|
||
auth,
|
||
isNot(contains('cannot be changed from the app')),
|
||
reason: 'riders set their own PIN on first sign-in',
|
||
);
|
||
});
|
||
|
||
test('sign-in no longer routes anyone to the unverified OTP screen', () {
|
||
// `verifyOtp` returned true without checking a digit, and the screen it
|
||
// led to could not write a PIN. Nothing may route there.
|
||
expect(signIn, isNot(contains('OtpPage')));
|
||
expect(signIn, contains('AuthNext.setPin'));
|
||
expect(signIn, contains('AuthNext.verifyPin'));
|
||
});
|
||
|
||
test('sign-in branches on the enum, never on response prose', () {
|
||
expect(
|
||
signIn,
|
||
isNot(contains('PIN verification required')),
|
||
reason: 'the message is prose and prose gets reworded',
|
||
);
|
||
});
|
||
});
|
||
}
|