import 'dart:convert'; 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/api_status.dart'; import 'package:miler/data/consignment_state.dart'; import 'package:miler/data/lifecycle.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/mock_backend.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE RIDER'S RUNG AND THE OFFICE'S RUNG ARE THE SAME RUNG /// /// ── The regression, measured against production 21 Aug 2026 ── /// /// ``` /// BEFORE GET /miler/bookings booking 78 = Miler_Assigned /// CALL POST /miler/bookings/78/reached /// → 200 {"success":true,"data":{"bookingid":78, /// "status":"Miler_Assigned"}} /// AFTER GET /miler/bookings booking 78 = Miler_Assigned /// ``` /// /// `reached` reports success and writes nothing. The app read only `ok`, drew /// ARRIVED, and the hub never heard about it — so a rider stood at a kitchen /// with a screen saying one thing and an operator watching a board that said /// another, and neither could tell the other was wrong. /// /// The rule these pin: **a 200 proves the call was accepted, never that the /// state moved.** The only proof of a state change is the state in the /// response. Every rung the app draws is now read that way, which is also what /// makes the app correct under both of the backend's two live pickup /// lifecycles without knowing which one is switched on. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); late List sent; http.Response jsonRes(Object body, [int code = 200]) => http.Response( json.encode(body), code, headers: const {'content-type': 'application/json'}, ); void stub(Map routes) { sent = []; MilerApi.client = MockClient((req) async { sent.add(req); for (final e in routes.entries) { if (req.url.path.endsWith(e.key)) return e.value; } return jsonRes({'success': true}); }); } setUp(() async { MockBackend.enabled = false; SharedPreferences.setMockInitialValues({'authtoken': 't'}); await ApiConfig.setToken('t'); stub(const {}); }); tearDown(() { MilerApi.client = http.Client(); MockBackend.enabled = kMockBackend; }); group('reached — arrival is only arrival if the server says so', () { test('the production no-op is reported, not believed', () async { // The exact body production returns today. stub({ '/reached': jsonRes({ 'success': true, 'data': {'bookingid': 78, 'status': 'Miler_Assigned'}, }), }); final t = MilerLifecycle.reached(await MilerApi.reached(78)); expect( t.isConfirmed, isFalse, reason: 'a 200 that leaves the booking on its old rung is not an ' 'arrival, however cheerful the envelope is', ); expect(t.isUnconfirmed, isTrue); expect(t.bookingStatus, BookingStatus.milerAssigned); expect( t.evidence, contains('Miler_Assigned'), reason: 'the log line has to be pasteable into a backend ticket', ); }); test('Arrived_At_Pickup is what confirms it', () async { stub({ '/reached': jsonRes({ 'success': true, 'data': {'bookingid': 78, 'status': 'Arrived_At_Pickup'}, }), }); final t = MilerLifecycle.reached(await MilerApi.reached(78)); expect(t.isConfirmed, isTrue); expect(t.bookingStatus, BookingStatus.arrivedAtPickup); expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); }); test('a response naming no status at all is not confirmation', () async { stub({ '/reached': jsonRes({'success': true, 'data': {}}), }); final t = MilerLifecycle.reached(await MilerApi.reached(78)); expect(t.isUnconfirmed, isTrue); expect(t.evidence, contains('no status')); }); test('a refusal is a refusal, with its code', () async { stub({ '/reached': jsonRes({ 'success': false, 'code': 'INVALID_STATE', 'message': 'not assigned to you', }, 400), }); final t = MilerLifecycle.reached(await MilerApi.reached(78)); expect(t.isRefused, isTrue); expect(t.code, 'INVALID_STATE'); }); }); group('pickup-complete — both live lifecycles, read from the response', () { test('the new flow stops at Collected_By_Miler', () async { stub({ '/pickup-complete': jsonRes({ 'success': true, 'data': { 'booking_status': 'Converted_To_Consignment', 'consignment_id': 46, 'consignmentstatus': 'Collected_By_Miler', 'next_action': 'start_delivery', }, }), }); final t = MilerLifecycle.pickupComplete( await MilerApi.pickupComplete(75), ); expect(t.isConfirmed, isTrue); expect(t.consignmentState, ConsignmentState.collectedByMiler); expect(t.consignmentId, '46'); expect(t.awaitsStartDelivery, isTrue); expect( t.isCompatibilityMode, isFalse, reason: 'the rider holds it — Start delivery is what makes it active', ); // The rung the rider and the office both see: collected, not delivering. expect( ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'), 'picked', ); }); test('compatibility mode is detected, not assumed', () async { // What production returns today, with the server flag off. stub({ '/pickup-complete': jsonRes({ 'success': true, 'data': { 'booking_status': 'Converted_To_Consignment', 'consignment_id': 46, 'consignmentstatus': 'Out_for_Delivery', }, }), }); final t = MilerLifecycle.pickupComplete( await MilerApi.pickupComplete(75), ); expect(t.isConfirmed, isTrue); expect(t.consignmentState, ConsignmentState.outForDelivery); expect( t.isCompatibilityMode, isTrue, reason: 'this is the fact the app must surface rather than paper over ' '— it is why the console shows Active for a fresh pickup', ); expect(t.awaitsStartDelivery, isFalse); // And the app must agree with the console rather than say "picked". expect( ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'), 'outfordelivery', ); }); test('no feature flag is mirrored — the same build reads both', () async { for (final (status, expected) in const [ ('Collected_By_Miler', ConsignmentState.collectedByMiler), ('Out_for_Delivery', ConsignmentState.outForDelivery), ]) { stub({ '/pickup-complete': jsonRes({ 'success': true, 'data': {'consignment_id': 46, 'consignmentstatus': status}, }), }); final t = MilerLifecycle.pickupComplete( await MilerApi.pickupComplete(75), ); expect(t.consignmentState, expected); } }); test('next_action alone is enough to place the new flow', () async { stub({ '/pickup-complete': jsonRes({ 'success': true, 'data': {'consignment_id': 46, 'next_action': 'start_delivery'}, }), }); final t = MilerLifecycle.pickupComplete( await MilerApi.pickupComplete(75), ); expect(t.consignmentState, ConsignmentState.collectedByMiler); expect(t.awaitsStartDelivery, isTrue); }); test('a pivot that minted no consignment is not confirmed', () async { stub({ '/pickup-complete': jsonRes({'success': true, 'data': {}}), }); final t = MilerLifecycle.pickupComplete( await MilerApi.pickupComplete(75), ); expect( t.isConfirmed, isFalse, reason: 'without an id there is nothing to deliver against, whatever ' 'the words say', ); }); }); group('the ladder, rung by rung', () { test('Accepted is where the rider starts', () { expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted'); expect(BookingStatus.parse('Pickup_Scheduled').isOpen, isTrue); expect(BookingStatus.parse('Pickup_Scheduled').isCollected, isFalse); }); test('Arrived does not move the work boundary', () { expect(BookingStatus.arrivedAtPickup.isCollected, isFalse); expect(BookingStatus.arrivedAtPickup.isOpen, isTrue); }); test('start-delivery is only for a collected consignment', () { expect(ConsignmentState.collectedByMiler.needsRelease, isTrue); expect( ConsignmentState.outForDelivery.needsRelease, isFalse, reason: 'calling start-delivery on an already-released consignment is ' 'a 4xx per press in the backend log', ); expect(ConsignmentState.inwardedAtHub.needsRelease, isFalse); }); test('only Out_for_Delivery is deliverable', () { expect(ConsignmentState.outForDelivery.isDeliverable, isTrue); expect(ConsignmentState.collectedByMiler.isDeliverable, isFalse); expect(ConsignmentState.inwardedAtHub.isDeliverable, isFalse); expect( ConsignmentGate.gateFor(ConsignmentState.collectedByMiler), DeliverGate.needsRelease, ); expect( ConsignmentGate.gateFor(ConsignmentState.outForDelivery), DeliverGate.deliverable, ); }); test('Delivered is terminal and reached only through deliver', () { expect(ConsignmentState.delivered.isDelivered, isTrue); expect(ConsignmentState.delivered.isDeliverable, isFalse); expect( ConsignmentGate.gateFor(ConsignmentState.delivered), DeliverGate.alreadyDelivered, ); }); test('the whole ladder maps to rider words with no enum leaking', () { // What the rider sees at each rung. Never the API's own spelling. expect(ApiConfig.legacyStatusFromNew('Miler_Assigned'), 'assigned'); expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted'); expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); expect( ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'), 'picked', ); expect( ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'), 'outfordelivery', ); expect(ApiConfig.legacyStatusFromConsignment('Delivered'), 'delivered'); }); }); }