import 'package:flutter_test/flutter_test.dart'; import 'package:miler/data/consignment_state.dart'; /// ───────────────────────────────────────────────────────────────────────── /// BOOKING WORDS ARE NOT CONSIGNMENT WORDS /// /// The delivery half of the lifecycle lives on the consignment, and the app /// spent a long time inferring it from the booking — whose story ends at /// `Converted_To_Consignment` and never mentions delivery at all. These pin /// the vocabulary boundary and the one state `deliver` accepts. /// /// Root cause this suite exists to prevent, verified against the live API on /// 2026-08-21: `POST /miler/consignments/:id/deliver` answers /// `400 consignment is not out for delivery` for **two opposite** situations — /// "still at the hub" and "already delivered". Consignment 34's own history /// read `Out_for_Delivery` then `Delivered`; the app showed the rider an error /// for work he had completed an hour earlier, because the only state it could /// see was the booking's terminal word. /// ───────────────────────────────────────────────────────────────────────── void main() { group('parsing', () { test('the consignment vocabulary is understood in every spelling', () { expect( consignmentStateFromRaw('Out_for_Delivery'), ConsignmentState.outForDelivery, ); expect( consignmentStateFromRaw('out for delivery'), ConsignmentState.outForDelivery, ); expect( consignmentStateFromRaw('OUTFORDELIVERY'), ConsignmentState.outForDelivery, ); expect(consignmentStateFromRaw('Delivered'), ConsignmentState.delivered); expect( consignmentStateFromRaw('Inwarded_at_Hub'), ConsignmentState.inwardedAtHub, ); }); test('BOOKING words are not consignment states', () { // The exact confusion that produced the bug: these are booking-side // words, and none of them may resolve to a deliverable consignment. for (final booking in const [ 'Converted_To_Consignment', 'Picked_Up', 'Pickup_Scheduled', 'Miler_Assigned', 'Pending_Pickup', ]) { final state = consignmentStateFromRaw(booking); expect( state, ConsignmentState.unknown, reason: '"$booking" is a BOOKING status; it must not name a ' 'consignment state', ); expect(state.isDeliverable, isFalse); } }); test('an unknown status is never deliverable and never finished', () { final s = consignmentStateFromRaw('Teleported'); expect(s, ConsignmentState.unknown); expect(s.isDeliverable, isFalse); expect(s.isDelivered, isFalse); expect(s.isClosed, isFalse); expect(s.awaitsHub, isFalse); expect(ConsignmentGate.gateFor(s), DeliverGate.unknown); }); test('empty and null are unknown, not deliverable', () { expect(consignmentStateFromRaw(null).isDeliverable, isFalse); expect(consignmentStateFromRaw('').isDeliverable, isFalse); }); }); group('the gate', () { test('only Out_for_Delivery may be delivered', () { for (final s in ConsignmentState.values) { expect( s.isDeliverable, s == ConsignmentState.outForDelivery, reason: '${s.name} must ${s == ConsignmentState.outForDelivery ? '' : 'not '}' 'be deliverable', ); } expect( ConsignmentGate.gateFor(ConsignmentState.outForDelivery), DeliverGate.deliverable, ); }); test('an already-delivered consignment reconciles, it does not error', () { expect( ConsignmentGate.gateFor(ConsignmentState.delivered), DeliverGate.alreadyDelivered, reason: 'the work is done server-side; the device is what is behind', ); }); test('a genuine hub hold stays blocked — the logistics guard', () { // The guard the brief requires be kept: a consignment inside the hub's // half of the network is not this rider's to hand over. // // `Created` is deliberately NOT in this list — see the test below. for (final s in const [ ConsignmentState.inwardedAtHub, ConsignmentState.tripsheetLoaded, ConsignmentState.inTransit, ]) { expect(s.awaitsHub, isTrue, reason: '${s.name} is hub-side'); expect(s.isDeliverable, isFalse); expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingHub); } }); test('`Created` is the rider\'s own parcel, not a hub hold', () { // The bug this pins: `Created` was in the hub list, so sliding **Start // ride** on a freshly converted consignment answered "This parcel is // with the hub — it will be delivered from there, not by you" about a // bag on the rider's own back, and nothing on that screen cleared it. // // The backend writes `Created` at the pivot for hub-routed work // (`next_action: inward_at_hub`) and holds it there transiently while // hyperlocal work is routed. The hub has not taken it in either way. const s = ConsignmentState.created; expect(s.awaitsHub, isFalse, reason: 'the hub has never seen it'); expect(s.awaitsHubInward, isTrue); expect(ConsignmentGate.gateFor(s), DeliverGate.awaitingInward); // Still not deliverable: `deliver` only accepts `Out_for_Delivery`. The // fix changes which sentence the rider is shown, never what is claimed. expect(s.isDeliverable, isFalse); expect(s.needsRelease, isFalse); // And no other state may drift into the new rung. for (final other in ConsignmentState.values) { if (other == ConsignmentState.created) continue; expect( other.awaitsHubInward, isFalse, reason: '${other.name} is not the pre-inward rung', ); } }); test('closed work is refused without blaming the rider', () { for (final s in const [ ConsignmentState.cancelled, ConsignmentState.returnedToSender, ]) { expect(ConsignmentGate.gateFor(s), DeliverGate.closed); expect(s.isDeliverable, isFalse); } }); test('an unreadable state does not block the rider', () async { // A failed GET is not evidence of anything. Blocking here would strand a // rider at a door because a read timed out; the server stays the judge. expect( ConsignmentGate.gateFor(ConsignmentState.unknown), DeliverGate.unknown, ); // No id is unreadable, and must not throw or hang. expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown); expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown); }); }); }