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/miler_api.dart'; import 'package:miler/data/mock_backend.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE RUNG BETWEEN THE COUNTER AND THE ROAD /// /// Backend contract change, 21 Aug 2026. Until then `pickup-complete` pushed a /// hyperlocal booking straight to `Out_for_Delivery`, so the hub's board said /// *actively delivering* for food still on the kitchen counter, and the app /// had nowhere honest to put the one fact only the rider knows — that he has /// set off. **Start round** could therefore only ever be a note to self. /// /// The pivot now stops at `Collected_By_Miler` and /// `POST /miler/consignments/:id/start-delivery` makes the release, so the /// press is a real write again. These pin the four pieces that has to hold: /// the state exists and is not mistaken for a hub hold, the gate routes it to /// the rider rather than blaming the warehouse, the release is asked of the /// server before anything local is written, and a list row's consignment /// status outranks the booking status that stopped being informative at /// conversion. /// ───────────────────────────────────────────────────────────────────────── void main() { TestWidgetsFlutterBinding.ensureInitialized(); late List sent; /// Answers every request with [body], and records what was asked. void stub({ Map body = const {'success': true}, int code = 200, }) { sent = []; MilerApi.client = MockClient((req) async { sent.add(req); return http.Response( json.encode(body), code, headers: const {'content-type': 'application/json'}, ); }); } /// Answers each path from [routes], falling back to a bare success. void stubRoutes(Map routes) { sent = []; MilerApi.client = MockClient((req) async { sent.add(req); for (final entry in routes.entries) { if (req.url.path.endsWith(entry.key)) return entry.value; } return http.Response( json.encode({'success': true}), 200, headers: const {'content-type': 'application/json'}, ); }); } http.Response jsonRes(Object body, [int code = 200]) => http.Response( json.encode(body), code, headers: const {'content-type': 'application/json'}, ); setUp(() async { MockBackend.enabled = false; SharedPreferences.setMockInitialValues({'authtoken': 'test-token'}); await ApiConfig.setToken('test-token'); stub(); }); tearDown(() { MilerApi.client = http.Client(); MockBackend.enabled = kMockBackend; }); group('the state itself', () { test('Collected_By_Miler parses, however it is spelled', () { for (final raw in [ 'Collected_By_Miler', 'collected_by_miler', 'COLLECTED BY MILER', ]) { expect( consignmentStateFromRaw(raw), ConsignmentState.collectedByMiler, reason: '$raw must not fall through to unknown — an unrecognised ' 'state blocks the door', ); } }); test('it is the rider holding the parcel, not the hub', () { const state = ConsignmentState.collectedByMiler; expect( state.awaitsHub, isFalse, reason: 'telling a rider the hub has the parcel in his own box is the ' 'exact error this state exists to prevent', ); expect(state.needsRelease, isTrue); expect( state.isDeliverable, isFalse, reason: 'deliver is refused until start-delivery has run', ); expect(state.isClosed, isFalse); }); test('a skip is reportable the moment the parcel is collected', () { // Widened server-side on 21 Aug 2026: a customer who is not home is not // home whether or not the rider remembered to press Start round. expect(ConsignmentState.collectedByMiler.canSkip, isTrue); expect(ConsignmentState.outForDelivery.canSkip, isTrue); expect(ConsignmentState.delivered.canSkip, isFalse); expect(ConsignmentState.inwardedAtHub.canSkip, isFalse); }); test('the gate sends the rider to Start round, not to the hub', () { expect( ConsignmentGate.gateFor(ConsignmentState.collectedByMiler), DeliverGate.needsRelease, ); expect( ConsignmentGate.gateFor(ConsignmentState.inwardedAtHub), DeliverGate.awaitingHub, reason: 'the genuine hub guard must survive this change', ); expect( ConsignmentGate.gateFor(ConsignmentState.delivered), DeliverGate.alreadyDelivered, ); expect( ConsignmentGate.gateFor(ConsignmentState.outForDelivery), DeliverGate.deliverable, ); }); }); group('reading the state', () { test('one call answers it, where it used to take a history', () async { stubRoutes({ '/miler/consignments/34': jsonRes({ 'success': true, 'data': {'consignmentid': 34, 'status': 'Collected_By_Miler'}, }), }); expect( await ConsignmentGate.stateOf(34), ConsignmentState.collectedByMiler, ); expect(sent.single.method, 'GET'); expect( sent.single.url.path.endsWith('/miler/consignments/34'), isTrue, reason: 'the logs walk was one round trip and a sort per stop', ); }); test( 'the flags answer it when the word is one this build cannot read', () async { stubRoutes({ '/miler/consignments/34': jsonRes({ 'success': true, 'data': { 'status': 'Handed_To_Locker_Bank', 'collected': false, 'out_for_delivery': false, 'delivered': true, }, }), }); expect( await ConsignmentGate.stateOf(34), ConsignmentState.delivered, reason: 'delivered is read first: mistaking a finished delivery for an ' 'open one makes a rider post work he has already done', ); }, ); test('an old deployment still answers, through the history', () async { // A rider mid-round on this build meeting a server that has not shipped // the route yet must not be stranded at a door. stubRoutes({ '/miler/consignments/logs/34': jsonRes([ {'historyid': 2, 'eventstatus': 'Out_for_Delivery'}, {'historyid': 1, 'eventstatus': 'Created'}, ]), '/miler/consignments/34': jsonRes({ 'success': false, 'message': 'not found', }, 404), }); expect( await ConsignmentGate.stateOf(34), ConsignmentState.outForDelivery, ); expect( sent.map((r) => r.url.path.endsWith('/miler/consignments/logs/34')), contains(true), ); }); test('a read that fails is not evidence of anything', () async { stub(body: const {'success': false, 'message': 'gateway'}, code: 502); expect(await ConsignmentGate.stateOf(34), ConsignmentState.unknown); expect( ConsignmentGate.gateFor(ConsignmentState.unknown), DeliverGate.unknown, reason: 'blocking on a timed-out GET strands a rider at a door', ); }); test('nothing is asked about a consignment that does not exist', () async { expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown); expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown); expect(sent, isEmpty); }); }); group('the release', () { test('start-delivery posts against the consignment', () async { await MilerApi.startDelivery(34); expect(sent.single.method, 'POST'); expect( sent.single.url.path.endsWith('/miler/consignments/34/start-delivery'), isTrue, ); }); test('it carries an idempotency key, stable across a retry', () async { await MilerApi.startDelivery(34); final first = sent.single.headers['Idempotency-Key']; stub(); await MilerApi.startDelivery(34); expect(first, isNotNull); expect( sent.single.headers['Idempotency-Key'], first, reason: 'a key that changes per press is not idempotency — a dropped ' 'response would release the round twice', ); }); test('a different consignment gets a different key', () async { await MilerApi.startDelivery(34); final first = sent.single.headers['Idempotency-Key']; stub(); await MilerApi.startDelivery(35); expect(sent.single.headers['Idempotency-Key'], isNot(first)); }); test( 'the write that is already running is waited out, not reported', () async { var calls = 0; sent = []; MilerApi.client = MockClient((req) async { sent.add(req); calls++; // The first attempt's response was lost; the server is running it. if (calls == 1) { return jsonRes({ 'success': false, 'code': 'IDEMPOTENCY_IN_PROGRESS', 'message': 'in progress', }, 409); } return jsonRes({ 'success': true, 'data': {'status': 'Out_for_Delivery'}, }); }); final res = await MilerApi.startDelivery(34); expect( res.ok, isTrue, reason: 'telling a rider his round failed while it is in the act of ' 'succeeding sends him to press the same refusal again', ); expect(calls, 2); }, ); test('a failure names the code, not just the prose', () async { stub( body: const { 'success': false, 'code': 'INVALID_STATE', 'message': 'consignment is not collected', }, code: 400, ); final res = await MilerApi.startDelivery(34); expect(res.ok, isFalse); expect( res.isInvalidState, isTrue, reason: 'branching on message text breaks the day it is reworded', ); }); }); group('the list row carries the delivery half', () { Map booking(String status, {String? consignment}) => { 'bookingid': 59, 'bookingreference': 'DM-BK-501CB551-45130', 'status': status, if (consignment != null) 'consignmentstatus': consignment, 'consignmentid': 34, }; test('the consignment id arrives on the row, so nothing has to hunt', () { // The 17-of-29 blocker: bookings carried no consignment id and // /miler/assignments listed only 12 of them, so for 17 collected stops // the delivery reference was unobtainable by any route. final stop = ApiConfig.pickupFromBooking( booking('Converted_To_Consignment', consignment: 'Collected_By_Miler'), ); expect(stop['consignmentid'], '34'); }); test('a delivered consignment is not an open delivery stop', () { final stop = ApiConfig.pickupFromBooking( booking('Converted_To_Consignment', consignment: 'Delivered'), ); expect( stop['orderstatus'], 'delivered', reason: 'the booking word is terminal at conversion and says the same ' 'thing for a parcel in the box and one handed over an hour ago', ); }); test('collected and on-the-road are told apart on the list', () { expect( ApiConfig.pickupFromBooking( booking( 'Converted_To_Consignment', consignment: 'Collected_By_Miler', ), )['orderstatus'], 'picked', ); expect( ApiConfig.pickupFromBooking( booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'), )['orderstatus'], 'outfordelivery', ); }); test('a hub-side consignment keeps the booking’s own word', () { // Not this rider's parcel to act on, and it has no rung on his card — // inventing one would draw somebody else's warehouse on his screen. final stop = ApiConfig.pickupFromBooking( booking('Converted_To_Consignment', consignment: 'Inwarded_at_Hub'), ); expect(stop['orderstatus'], 'picked'); }); test('a row with no consignment status behaves exactly as before', () { expect( ApiConfig.pickupFromBooking(booking('Pickup_Scheduled'))['orderstatus'], 'accepted', ); expect( ApiConfig.pickupFromBooking( booking('Converted_To_Consignment'), )['orderstatus'], 'picked', ); }); }); group('arrival is a rung the hub can see', () { test('Arrived_At_Pickup is the status reached writes', () { expect( BookingStatus.parse('Arrived_At_Pickup'), BookingStatus.arrivedAtPickup, ); expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived'); }); test('the older At_Customer spelling means the same thing', () { expect(BookingStatus.parse('At_Customer'), BookingStatus.arrivedAtPickup); expect(ApiConfig.legacyStatusFromNew('At_Customer'), 'arrived'); }); test('arriving is not collecting', () { expect( BookingStatus.arrivedAtPickup.isCollected, isFalse, reason: 'the work boundary must not move a bag the rider has not ' 'picked up yet onto the delivery tab', ); expect(BookingStatus.arrivedAtPickup.isOpen, isTrue); expect(BookingStatus.arrivedAtPickup.canCancel, isTrue); }); }); }