Files
doormile_milderapp/test/hub_handover_action_test.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

250 lines
9.8 KiB
Dart

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<String> sent;
late List<String> 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<String, dynamic> 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 = <String>[];
bodies = <String>[];
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);
});
}