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
223 lines
8.9 KiB
Dart
223 lines
8.9 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/mutation_guard.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:miler/Models/stop_status.dart';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// THE DELIVERY WRITES AGAINST THE CONSIGNMENT, ONCE
|
||
///
|
||
/// Three ids exist for one job and they come from three different sequences:
|
||
///
|
||
/// `bookingid` the customer's booking
|
||
/// `bookingassignmentid` the row that gave it to this rider
|
||
/// `consignmentid` what the booking BECAME at pickup-complete
|
||
///
|
||
/// `accept`/`reject` key on the assignment, `reached`/`pickup-complete` on the
|
||
/// booking, and `deliver`/`skip` on the **consignment**. Passing the wrong one
|
||
/// does not fail loudly — the backend answers 404, or worse finds a row that
|
||
/// happens to share the number — so these pin the mapping the delivery path
|
||
/// depends on.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
void main() {
|
||
setUp(() {
|
||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||
});
|
||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||
|
||
group('pickup-complete preserves the real consignment id', () {
|
||
test('the id minted at the pivot survives to the door', () async {
|
||
// `pickup-complete` is the only moment the consignment id is guaranteed
|
||
// to be in hand: `GET /miler/bookings` does not return it at all
|
||
// (verified against the live API), so if the pivot does not record it,
|
||
// the delivery leg has nothing to write against.
|
||
await rememberConsignmentId('ORDER-1', '46');
|
||
final ids = await getConsignmentIds();
|
||
expect(ids['ORDER-1'], '46');
|
||
});
|
||
|
||
test('a booking id is never mistaken for a consignment id', () async {
|
||
// Booking 75 became consignment 46 — different sequences, and the
|
||
// mapping is keyed by the ORDER, never by the number itself.
|
||
await rememberConsignmentId('ORDER-1', '46');
|
||
final ids = await getConsignmentIds();
|
||
expect(
|
||
ids['ORDER-1'],
|
||
isNot('75'),
|
||
reason: 'the booking id must never end up in the consignment slot',
|
||
);
|
||
expect(
|
||
ids.containsKey('75'),
|
||
isFalse,
|
||
reason: 'the map is keyed by order, not by booking number',
|
||
);
|
||
});
|
||
|
||
test('the mapping is per rider — and follows him across lines', () async {
|
||
// ── This asserted the opposite until 2026-09-16 ──
|
||
//
|
||
// The scope key carried a third part, the rider's service line:
|
||
// `consignment_ids::u38.t13.milkMan`. That was right while a rider was
|
||
// one thing for the life of his account. He is not — one Miler carries
|
||
// meal parcels, logistics collections and customer pickups in the same
|
||
// shift — and keying his records by a line split one day's work across
|
||
// two drawers, with whichever drawer the app resolved into being the only
|
||
// one he could see.
|
||
//
|
||
// For a *consignment id* that was not a cosmetic loss: the id is what
|
||
// `deliver` and `skip` key on, so a stop collected while the app read one
|
||
// line and delivered after it read the other could not be closed at all.
|
||
// The rider was told "this order was never picked up on the system".
|
||
await rememberConsignmentId('ORDER-1', '46');
|
||
|
||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||
expect(
|
||
(await getConsignmentIds())['ORDER-1'],
|
||
'46',
|
||
reason: 'one rider, one day, one drawer — whatever he is carrying',
|
||
);
|
||
|
||
SharedPreferences.setMockInitialValues({'userid': 99});
|
||
expect(
|
||
(await getConsignmentIds())['ORDER-1'],
|
||
isNull,
|
||
reason: "another rider's handset drawer is still not this one",
|
||
);
|
||
});
|
||
|
||
test('a delivered order forgets its mapping', () async {
|
||
await rememberConsignmentId('ORDER-1', '46');
|
||
await forgetConsignmentIds(['ORDER-1']);
|
||
expect(
|
||
(await getConsignmentIds())['ORDER-1'],
|
||
isNull,
|
||
reason: 'nothing else may be posted against a closed consignment',
|
||
);
|
||
});
|
||
});
|
||
|
||
group('a missing consignment id is reported honestly', () {
|
||
// Verified against production 21 Aug 2026: booking 59 is
|
||
// `Converted_To_Consignment` and on the Deliveries tab, yet no route
|
||
// exposes its consignment id — `/miler/bookings` carries none on any of
|
||
// its 29 rows and `/miler/assignments` lists only 12 of those bookings.
|
||
//
|
||
// The app cannot deliver it. What it CAN do is tell the rider the truth:
|
||
// a collected order with an unreachable reference is a hub problem, not
|
||
// "you forgot to pick it up" — advice that sends him to look for a stop
|
||
// that is not on Home, to redo work he has already done.
|
||
test('a collected stop and an uncollected one are different states', () {
|
||
// `Converted_To_Consignment` maps to `picked` — collected.
|
||
expect(stopStatusFromRaw('picked').isPicked, isTrue);
|
||
expect(stopStatusFromRaw('outfordelivery').isDeliveryLeg, isTrue);
|
||
|
||
// Still awaiting collection — the "pick it up from Home" advice is
|
||
// correct only for these.
|
||
expect(stopStatusFromRaw('assigned').isPicked, isFalse);
|
||
expect(stopStatusFromRaw('assigned').isDeliveryLeg, isFalse);
|
||
expect(stopStatusFromRaw('accepted').isPicked, isFalse);
|
||
expect(stopStatusFromRaw('accepted').isDeliveryLeg, isFalse);
|
||
});
|
||
});
|
||
|
||
group('one press, one write', () {
|
||
test('a duplicate Delivered tap produces one mutation', () async {
|
||
var calls = 0;
|
||
Future<String> deliver() async {
|
||
calls++;
|
||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||
return 'done';
|
||
}
|
||
|
||
// Both taps land while the first request is still in flight — which is
|
||
// what a gloved thumb on a moving bike actually produces.
|
||
final first = MutationGuard.run('delivered:ORDER-1', deliver);
|
||
final second = MutationGuard.run('delivered:ORDER-1', deliver);
|
||
final results = await Future.wait([first, second]);
|
||
|
||
expect(calls, 1, reason: 'the second press must be dropped, not queued');
|
||
expect(results.where((r) => r != null).length, 1);
|
||
expect(
|
||
results.contains(null),
|
||
isTrue,
|
||
reason: 'the dropped press returns null so the caller keeps the stop',
|
||
);
|
||
});
|
||
|
||
test('the guard frees the key so a genuine retry still works', () async {
|
||
var calls = 0;
|
||
Future<String> deliver() async {
|
||
calls++;
|
||
return 'done';
|
||
}
|
||
|
||
await MutationGuard.run('delivered:ORDER-1', deliver);
|
||
await MutationGuard.run('delivered:ORDER-1', deliver);
|
||
expect(
|
||
calls,
|
||
2,
|
||
reason: 'a retry after the first completed is a real second attempt',
|
||
);
|
||
});
|
||
|
||
test('two different stops do not block each other', () async {
|
||
var a = 0;
|
||
var b = 0;
|
||
await Future.wait([
|
||
MutationGuard.run('delivered:ORDER-1', () async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||
a++;
|
||
return 'a';
|
||
}),
|
||
MutationGuard.run('delivered:ORDER-2', () async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||
b++;
|
||
return 'b';
|
||
}),
|
||
]);
|
||
expect(a, 1);
|
||
expect(b, 1);
|
||
});
|
||
|
||
test('the outcome is part of the key', () async {
|
||
// Delivered and Skipped are different writes against the same stop; one
|
||
// must not silently swallow the other.
|
||
var delivered = 0;
|
||
var skipped = 0;
|
||
await Future.wait([
|
||
MutationGuard.run('delivered:ORDER-1', () async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||
delivered++;
|
||
return 'd';
|
||
}),
|
||
MutationGuard.run('skipped:ORDER-1', () async {
|
||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||
skipped++;
|
||
return 's';
|
||
}),
|
||
]);
|
||
expect(delivered, 1);
|
||
expect(skipped, 1);
|
||
});
|
||
});
|
||
|
||
group('completing a delivery clears the rider’s hands', () {
|
||
test('a delivered order stops being carried', () async {
|
||
await addCollectedOrderIds(['ORDER-1', 'ORDER-2']);
|
||
expect(await getCollectedOrderIds(), containsAll(['ORDER-1', 'ORDER-2']));
|
||
|
||
await removeCollectedOrderIds(['ORDER-1']);
|
||
final carried = await getCollectedOrderIds();
|
||
expect(carried, isNot(contains('ORDER-1')));
|
||
expect(
|
||
carried,
|
||
contains('ORDER-2'),
|
||
reason: 'closing one stop must not drop the rest of the load',
|
||
);
|
||
});
|
||
});
|
||
}
|