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 sent; /// Installs a fake server. [handler] answers by path. void serve(http.Response Function(http.Request req) handler) { sent = []; MilerApi.client = MockClient((req) async { sent.add(req); return handler(req); }); } setUp(() => SharedPreferences.setMockInitialValues({})); tearDown(() => MilerApi.client = http.Client()); Map bodyOf(http.Request r) => jsonDecode(r.body) as Map; /// 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', ); }); }); }