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/geofence.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/next_leg.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/pickups/pickups.dart'; import 'geofence_test.dart' show kTargetLat, kTargetLng, riderAt; /// ───────────────────────────────────────────────────────────────────────── /// THE BASE HANDOVER, AS A RIDER CAN NOW ACTUALLY PERFORM IT /// /// `MilerApi.inwardAtHub` and `MilerLifecycle.inwardAtHub` were written, /// documented and covered by `hub_handover_contract_test.dart` — and had **zero /// call sites**. So with `MILER_HUB_HANDOVER_ENABLED` on server-side, a /// Gandhipuram → Chennai parcel arrived on Deliveries as [NextLeg.hub], /// `releaseForDelivery` correctly refused to start a customer round for it, and /// nothing in the app could record the rider handing it in. It sat in his queue /// until hub staff inwarded it from a console, and the assignment closed /// against nobody — so the job reported zero distance and zero value on his /// earnings. /// /// `hub_handover_contract_test.dart` still owns the *contract* — the adapter /// fields, the `next_action` vocabulary, both positions of the server flag, and /// how the response is read. This file owns the *action*: that a rider standing /// at the base can close the leg, that one standing anywhere else cannot, and /// that nothing is posted when the fence refuses. /// ───────────────────────────────────────────────────────────────────────── void main() { late List sent; late List bodies; /// The row as `GET /miler/bookings` returns it with the server flag ON: the /// pivot left the consignment `Created` and pointed the rider at a base. Map hubRow() => ApiConfig.pickupFromBooking({ 'bookingid': 4821, 'orderid': 'ORDER-HUB-1', 'status': 'Converted_To_Consignment', 'consignmentstatus': 'Created', 'consignmentid': 77, 'next_action': 'inward_at_hub', 'next_hub': { 'id': 1, 'name': 'Coimbatore Base', 'address': '14 Avinashi Road, Peelamedu', 'pincode': '641004', 'latitude': kTargetLat, 'longitude': kTargetLng, }, }); setUp(() { ServiceProfile.setActive(ServiceProfile.parcel); SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'}); sent = []; bodies = []; MilerApi.client = MockClient((req) async { sent.add('${req.method} ${req.url.path}'); bodies.add(req.body); return http.Response( '{"success":true,"data":{"consignmentstatus":"Inwarded_at_Hub",' '"inwardedat":"2026-09-16T14:22:10Z","next_action":"handed_to_hub",' '"already_inwarded":false}}', 200, ); }); }); tearDown(() { MilerApi.client = http.Client(); Geofence.useDevice(); ServiceProfile.setActive(ServiceProfile.parcel); }); group('the leg is read from the row', () { test('a base-routed row resolves to a base leg and names its base', () { final row = hubRow(); expect(NextLegResolver.resolve(row).leg, NextLeg.hub); final base = handoverBaseFor(row); expect(base, isNotNull); expect(base!.name, 'Coimbatore Base'); expect(base.isNavigable, isTrue); }); test('a customer-routed row is not a base leg and has no base', () { final row = ApiConfig.pickupFromBooking({ 'bookingid': 4822, 'orderid': 'ORDER-CX-1', 'consignmentstatus': 'Out_for_Delivery', 'next_action': 'deliver', }); expect(NextLegResolver.resolve(row).leg, NextLeg.customer); expect(handoverBaseFor(row), isNull); }); }); group('the fence guards the handover like every other presence claim', () { test('250 m from the base — refused, and NOTHING is posted', () async { Geofence.positionProvider = () async => GeofenceFix(position: riderAt(250)); final ok = await handOverAtHub(hubRow()); expect(ok, isFalse); expect(sent, isEmpty, reason: 'a refused handover must not reach inward-at-hub'); expect(lastHandoverFailure, contains('Move within 100 m')); expect(lastHandoverFailure, contains('handed over')); }); test('GPS off — refused, and nothing is posted', () async { Geofence.positionProvider = () async => const GeofenceFix(failure: GeofenceOutcome.serviceDisabled); expect(await handOverAtHub(hubRow()), isFalse); expect(sent, isEmpty); expect(lastHandoverFailure, contains('Location is switched off')); }); test('a base with no coordinates is refused, not waved through', () async { // The old doctrine allowed a target-less stop through on the argument // that the rider cannot fix the hub's data from a doorstep. True — and it // made the fence optional for exactly the stops nobody could audit. Geofence.positionProvider = () async => GeofenceFix(position: riderAt(0)); final row = hubRow(); row['next_hub'] = const {'id': 2, 'name': 'Salem Base'}; expect(await handOverAtHub(row), isFalse); expect(sent, isEmpty); expect(lastHandoverFailure, contains('office')); }); }); group('inside the fence, the leg actually closes', () { setUp(() { Geofence.positionProvider = () async => GeofenceFix(position: riderAt(30)); }); test('it posts inward-at-hub, with the base and the judged position', () async { final ok = await handOverAtHub(hubRow()); expect(ok, isTrue); expect(sent, hasLength(1)); expect(sent.single, contains('/miler/consignments/77/inward-at-hub')); // The body the contract asks for: the base it is reconciled against, and // the coordinates stamped onto the history row as evidence of where the // hand-over happened. expect(bodies.single, contains('"hub_id":"1"')); expect(bodies.single, contains('"latitude"')); expect(bodies.single, contains('"longitude"')); expect(lastHandoverFailure, isNull); }); test('the position posted is the one the fence judged', () async { final fix = riderAt(30); Geofence.positionProvider = () async => GeofenceFix(position: fix); await handOverAtHub(hubRow()); // Not a second GPS read. Two fixes seconds apart are two different // answers, and the hub should be told the one the app allowed on. expect(bodies.single, contains(fix.latitude.toString())); }); test('a parcel already inwarded is a success, not a failure', () async { // He handed it over, the reply was lost, he pressed again at a loading // bay with one bar of signal. The parcel is on the base's counter and he // cannot prove that from where he stands. MilerApi.client = MockClient((req) async { sent.add('${req.method} ${req.url.path}'); return http.Response( '{"success":true,"data":{"consignmentstatus":"Inwarded_at_Hub",' '"already_inwarded":true}}', 200, ); }); expect(await handOverAtHub(hubRow()), isTrue); }); test('a 200 that names no state is NOT treated as a handover', () async { // The rule `reached` taught this app the hard way: a bare success is not // a transition. A rider must not walk away from a parcel on the strength // of one. MilerApi.client = MockClient((req) async { sent.add('${req.method} ${req.url.path}'); return http.Response('{"success":true}', 200); }); expect(await handOverAtHub(hubRow()), isFalse); expect(lastHandoverFailure, contains('did not confirm')); expect(lastHandoverFailure, contains('before you leave the parcel')); }); test('a server refusal is passed through in the server\'s own words', () async { MilerApi.client = MockClient((req) async { sent.add('${req.method} ${req.url.path}'); return http.Response( '{"success":false,"code":"INVALID_STATE",' '"message":"already out for delivery"}', 400, ); }); expect(await handOverAtHub(hubRow()), isFalse); expect(lastHandoverFailure, contains('already out for delivery')); }); }); test('a customer leg is never posted to inward-at-hub', () async { Geofence.positionProvider = () async => GeofenceFix(position: riderAt(0)); final row = ApiConfig.pickupFromBooking({ 'bookingid': 4822, 'orderid': 'ORDER-CX-1', 'consignmentstatus': 'Out_for_Delivery', 'consignmentid': 78, 'next_action': 'deliver', }); expect(await handOverAtHub(row), isFalse); expect(sent, isEmpty); expect(lastHandoverFailure, contains('not going to a base')); }); test('with the server flag OFF the rider never reaches this rung', () { // `pickup-complete` inwards the parcel itself and the row comes back // `Inwarded_at_Hub` / `handed_to_hub`, which is [NextLeg.closed]. The sheet // draws no base CTA and this function is never called. The app reads the // row and never mirrors the flag — that is what makes one build correct in // both positions of it. final row = ApiConfig.pickupFromBooking({ 'bookingid': 4821, 'status': 'Converted_To_Consignment', 'consignmentstatus': 'Inwarded_at_Hub', 'next_action': 'handed_to_hub', }); expect(NextLegResolver.resolve(row).leg, NextLeg.closed); }); }