Files
doormile_milderapp/test/multi_destination_stop_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

194 lines
8.3 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/milk_run.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ONE PICKUP, SEVERAL DOORS
///
/// A customer-app booking carries N destinations. `GET /miler/bookings`
/// returns ONE ROW PER DESTINATION once the visit has been collected — same
/// `bookingid`, same `bookingreference`, different door, distinguished only by
/// `destinationseq` / `destinationcount`.
///
/// Everything the device remembers about a stop is keyed on `orderid`: the
/// accepted store dedupes on it (`byId[id] = copy`), the consignment-id map
/// files under it, the collected and out-for-delivery sets hold it. So three
/// doors sharing one `orderid` was not a display bug — two of the three rows
/// were deduped away before any screen saw them, and the two consignment ids
/// that lost the race were unrecoverable. A rider holding three bags could
/// deliver one.
///
/// These tests pin the two halves of the fix that can silently rot:
///
/// 1. The **suffix exists only where there is something to tell apart**, so
/// every single-destination booking — which is all of logistics and all of
/// the milk run — keys exactly as it did before.
/// 2. **Collection is per visit, delivery is per door.** The collected set is
/// written once, under the booking key, while the pickup is still a single
/// pre-pickup row; the rows that replace it each carry their own key and
/// must still read as collected.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
/// One row of `GET /miler/bookings` for a three-destination customer pickup,
/// after `pickup-complete` has fanned it out.
Map<String, dynamic> destinationRow(int seq, {int count = 3}) => {
'bookingid': 482913,
'bookingreference': 'DM-482913',
'status': 'Converted_To_Consignment',
'destinationseq': seq,
'destinationcount': count,
'consignmentid': 500 + seq,
'trackingno': 'DM-TRK-884102$seq',
'recipientname': ['Anitha R', 'Suresh K', 'Meena P'][seq],
'recipientphone': '987654321$seq',
'deliveryaddress': ['Chennai', 'Ernakulam', 'Bengaluru'][seq],
'customername': 'Ravi Kumar', // the SENDER — not at any of the three doors
'pickupaddress': 'Gandhipuram, Coimbatore',
};
group('the three doors are three stops', () {
test('each destination gets its own key', () {
final keys = [
for (var seq = 0; seq < 3; seq++)
MilkRun.idOf(ApiConfig.pickupFromBooking(destinationRow(seq))),
];
expect(keys.toSet(), hasLength(3), reason: 'three doors, three keys');
expect(keys, ['DM-482913#0', 'DM-482913#1', 'DM-482913#2']);
});
test('a store keyed by orderid keeps all three', () {
// Exactly what addAcceptedBookings does: `byId[orderid] = row`.
final byId = <String, Map<String, dynamic>>{};
for (var seq = 0; seq < 3; seq++) {
final stop = ApiConfig.pickupFromBooking(destinationRow(seq));
byId[MilkRun.idOf(stop)] = stop;
}
expect(byId, hasLength(3), reason: 'none of the three deduped away');
});
test('each keeps its own consignment id', () {
// The map rememberConsignmentId writes into. One slot per door, so no
// door can lose its parcel to whichever wrote last.
final consignments = <String, String>{};
for (var seq = 0; seq < 3; seq++) {
final stop = ApiConfig.pickupFromBooking(destinationRow(seq));
consignments[MilkRun.idOf(stop)] = MilkRun.consignmentIdOf(stop);
}
expect(consignments.values.toSet(), {'500', '501', '502'});
});
test('the booking key is recoverable from any of them', () {
for (var seq = 0; seq < 3; seq++) {
final stop = ApiConfig.pickupFromBooking(destinationRow(seq));
expect(MilkRun.bookingKeyOf(stop), 'DM-482913');
expect(stop['bookingreference'], 'DM-482913');
}
});
});
group('the suffix appears only where it is needed', () {
test('a single-destination booking keys exactly as before', () {
final stop = ApiConfig.pickupFromBooking({
'bookingid': 77,
'bookingreference': 'DM-000077',
'status': 'Pending_Pickup',
'destinationseq': 0,
'destinationcount': 1,
});
expect(MilkRun.idOf(stop), 'DM-000077');
expect(MilkRun.bookingKeyOf(stop), 'DM-000077');
});
test('a booking with no destination fields at all is untouched', () {
// Every console booking, and any backend that has not shipped the
// destination fields yet. The absence must cost nothing.
final stop = ApiConfig.pickupFromBooking({
'bookingid': 42,
'bookingreference': 'DM-000042',
'status': 'Pending_Pickup',
});
expect(MilkRun.idOf(stop), 'DM-000042');
expect(MilkRun.destinationCountOf(stop), 1);
expect(MilkRun.stopLabel(stop), isEmpty);
});
});
group('the rider is told which door this is', () {
test('Stop N of M, counting from one', () {
final stop = ApiConfig.pickupFromBooking(destinationRow(1));
expect(MilkRun.stopLabel(stop), 'Stop 2 of 3');
});
test('says nothing when there is only one door', () {
final stop = ApiConfig.pickupFromBooking(destinationRow(0, count: 1));
expect(
MilkRun.stopLabel(stop),
isEmpty,
reason: 'a label on every card in the app stops being read',
);
});
test('the receiver has a number of their own to ring', () {
// The card dials `recipientphone` on a delivery leg, not the booking's
// `pickupcontactno` — that one is the SENDER, and ringing him from the
// receiver's gate is what it used to do at all three gates.
final stop = ApiConfig.pickupFromBooking(destinationRow(0));
expect(stop['recipientphone'], '9876543210');
expect(
stop['pickupcontactno'],
isNot(stop['recipientphone']),
reason: 'sender and receiver are different people',
);
});
test('the receiver and the tracking number survive the adapter', () {
// The adapter builds a fixed map, so a field it does not name is a field
// the UI can never see. These three were unnamed until now, which put the
// SENDER's name over the receiver's door.
final stop = ApiConfig.pickupFromBooking(destinationRow(2));
expect(stop['recipientname'], 'Meena P');
expect(stop['recipientphone'], '987654321 2'.replaceAll(' ', ''));
expect(stop['trackingno'], 'DM-TRK-8841022');
expect(
stop['pickupcustomer'],
'Ravi Kumar',
reason: 'the sender is still carried, just no longer as the drop',
);
});
});
group('collection is per visit, delivery is per door', () {
test('a collected visit keeps its doors collected after the split', () {
// The rider collected the pickup while it was still ONE row, so this is
// what the collected set holds. Nothing in it mentions a destination.
final collected = {'DM-482913'};
for (var seq = 0; seq < 3; seq++) {
final stop = ApiConfig.pickupFromBooking(destinationRow(seq));
expect(
MilkRun.wasCollected(stop, collected),
isTrue,
reason:
'door $seq must not drop back to "not collected" at the moment '
'the rider actually has the bag in his hands',
);
}
});
test('a door the rider has not collected still reads as uncollected', () {
final stop = ApiConfig.pickupFromBooking(destinationRow(0));
expect(MilkRun.wasCollected(stop, {'DM-999999'}), isFalse);
expect(MilkRun.wasCollected(stop, const {}), isFalse);
});
test('a per-door key in the set also counts', () {
// Once the delivery half starts writing per-door keys, both spellings
// have to answer — the set can hold either during a single shift.
final stop = ApiConfig.pickupFromBooking(destinationRow(1));
expect(MilkRun.wasCollected(stop, {'DM-482913#1'}), isTrue);
});
});
}