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
This commit is contained in:
@@ -207,10 +207,22 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('the other line’s records are not this one’s', () async {
|
||||
test('but the other line’s records ARE this one’s', () async {
|
||||
// Reversed 2026-09-16 with the removal of the line from [WorkScope].
|
||||
// Under one-Miler this rule lost a rider his own work: a meal drop and a
|
||||
// parcel collection finished in the same shift are both his, and filing
|
||||
// them in two drawers meant Activity showed him half his day and no
|
||||
// explanation for the other half.
|
||||
//
|
||||
// Ownership is still enforced — see the test above, which is the one that
|
||||
// was actually protecting anything.
|
||||
await addCompletedBookings([done('a')], terminalStatus: 'delivered');
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(await getCompletedOrderIds(), isEmpty);
|
||||
expect(
|
||||
await getCompletedOrderIds(),
|
||||
contains('a'),
|
||||
reason: 'one rider, one day, one history',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
103
test/arrived_needs_the_rider_there_test.dart
Normal file
103
test/arrived_needs_the_rider_there_test.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ARRIVED IS A REPORT OF WHERE THE RIDER IS, SO IT HAS TO BE TRUE
|
||||
///
|
||||
/// `_advanceStop` writes the ARRIVED rung optimistically, and the reasoning for
|
||||
/// that is sound and stays: a rider standing at a counter with no signal must
|
||||
/// not have his own report snapped back because a poll was late.
|
||||
///
|
||||
/// What was wrong is that `updateArrivedStatus` answers `false` for THREE
|
||||
/// different things and the caller treated all three as that one:
|
||||
///
|
||||
/// 1. the request never landed — no signal. Optimistic is right.
|
||||
/// 2. **the geofence refused** — the rider is 1.4km from the door and no
|
||||
/// request was ever made.
|
||||
/// 3. **the server refused** — it was asked and said no, on a business rule.
|
||||
///
|
||||
/// On 2 and 3 the app wrote ARRIVED onto a stop the rider had not reached. The
|
||||
/// admin console only ever hears what the server was told, so it went on showing
|
||||
/// the stop as pending — arrived on the phone, not arrived in the office, with
|
||||
/// no way for either side to know which was right. That is the divergence
|
||||
/// reported from the field.
|
||||
///
|
||||
/// Read from source: `_advanceStop` is a private method on a State class in a
|
||||
/// 4,000-line page, and standing up GetX, ScreenUtil, Geolocator and a live
|
||||
/// controller to assert one branch would test the harness more than the rule.
|
||||
/// The regression class here is *a missing guard*, and the cheapest honest
|
||||
/// check for a missing guard is to look for it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
final homepage = File(
|
||||
'lib/views/Dashboard/home/homepage.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
final controller = File(
|
||||
'lib/controllers/pickups_controller.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
group('a blocked arrival does not advance the rung', () {
|
||||
test('the caller consults the geofence verdict before advancing', () {
|
||||
expect(
|
||||
homepage,
|
||||
contains('dc.lastBlockedReason'),
|
||||
reason:
|
||||
'Without this the optimistic branch advances ARRIVED on a stop the '
|
||||
'geofence just refused, and the console never hears about it.',
|
||||
);
|
||||
});
|
||||
|
||||
test('it also stops on a refusal the server actually sent', () {
|
||||
expect(
|
||||
homepage,
|
||||
contains('dc.lastArrivalRefusal'),
|
||||
reason:
|
||||
'A 4xx is the office saying no. Advancing past it shows the rider '
|
||||
'as arrived somewhere the office has on record he is not.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the reason is handed back, not swallowed', () {
|
||||
// A rider who is simply told "refused" slides again at a stop that will
|
||||
// keep refusing. The geofence reason carries the distance and the target.
|
||||
expect(homepage, contains('if (notThere != null) return notThere;'));
|
||||
});
|
||||
});
|
||||
|
||||
group('a dead network still advances', () {
|
||||
test('the optimistic path is intact', () {
|
||||
// The fix narrowed the optimistic branch; it did not delete it. A rider
|
||||
// at a counter with no signal still gets his rung, and
|
||||
// `lastArrivalNotice` is what tells him the office has not been told.
|
||||
expect(controller, contains('lastArrivalNotice'));
|
||||
expect(
|
||||
controller,
|
||||
contains('Marked arrived on your phone'),
|
||||
reason: 'the no-signal notice is the half that must NOT become a block',
|
||||
);
|
||||
});
|
||||
|
||||
test('a transport failure sets no refusal, so it falls through', () {
|
||||
// `lastArrivalRefusal` is assigned null when the code is below 400 —
|
||||
// that null is load-bearing now, because it is what lets the optimistic
|
||||
// branch still run for a rider with no signal.
|
||||
expect(controller, contains('lastArrivalRefusal = code >= 400'));
|
||||
});
|
||||
});
|
||||
|
||||
group('the verdicts are fresh, not left over', () {
|
||||
test('an arrival clears both signals on entry', () {
|
||||
// Read only when the write fails, and the caller now acts on them — so a
|
||||
// value left from an earlier stop would refuse an arrival because a
|
||||
// different door refused twenty minutes ago.
|
||||
expect(controller, contains('lastArrivalRefusal = null'));
|
||||
expect(controller, contains('lastArrivalNotice = null'));
|
||||
});
|
||||
|
||||
test('the geofence check clears its own reason on entry', () {
|
||||
expect(controller, contains('lastBlockedReason = null'));
|
||||
});
|
||||
});
|
||||
}
|
||||
233
test/base_handover_card_test.dart
Normal file
233
test/base_handover_card_test.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/pickups.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A HANDOVER CARD NAMES THE BASE IT IS SENDING HIM TO
|
||||
///
|
||||
/// The card already refused to lead with the receiver, and was right to — on a
|
||||
/// base leg he is in another district, and heading the card with him is the
|
||||
/// navigation mistake that rule exists to prevent.
|
||||
///
|
||||
/// But refusing the receiver only left the *pickup*, so a handover card read:
|
||||
///
|
||||
/// BASE HANDOVER
|
||||
/// Anitha R
|
||||
/// Gandhipuram
|
||||
///
|
||||
/// …the customer he had collected from an hour earlier, under a heading telling
|
||||
/// him to go to a base. The place he was actually going appeared nowhere on the
|
||||
/// card — only on the sheet's slider, after he had already set off. On a round
|
||||
/// with two bases that is a parcel left in the wrong building.
|
||||
///
|
||||
/// `next_hub` rides on the row (the backend sends all six fields: id, name,
|
||||
/// address, pincode, latitude, longitude), so the destination is knowable at
|
||||
/// card-build time and there is no excuse for the card not to say it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
Future<void> pump(WidgetTester tester, Widget child) async {
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(390, 844),
|
||||
builder: (_, _) => MaterialApp(
|
||||
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// A collected parcel routed to a base — the row as the queue returns it with
|
||||
/// `MILER_HUB_HANDOVER_ENABLED` on.
|
||||
Map<String, dynamic> hubStop({Map<String, dynamic>? hub}) =>
|
||||
ApiConfig.pickupFromBooking({
|
||||
'bookingid': 4821,
|
||||
'orderid': 'ORDER-HUB-1',
|
||||
'status': 'Converted_To_Consignment',
|
||||
'consignmentstatus': 'Created',
|
||||
'consignmentid': 77,
|
||||
'next_action': 'inward_at_hub',
|
||||
'sourcename': 'Anitha R',
|
||||
'pickupcustomer': 'Anitha R',
|
||||
'pickupaddress': '12 Gandhipuram, Coimbatore',
|
||||
'recipientname': 'Ravi Kumar',
|
||||
'dropaddress': '88 Anna Nagar, Chennai 600040',
|
||||
'next_hub':
|
||||
hub ??
|
||||
const {
|
||||
'id': 1,
|
||||
'name': 'Coimbatore Base',
|
||||
'address': '14 Avinashi Road, Peelamedu',
|
||||
'pincode': '641004',
|
||||
'latitude': 11.0272,
|
||||
'longitude': 76.9905,
|
||||
},
|
||||
});
|
||||
|
||||
/// The same parcel, going to a customer instead.
|
||||
Map<String, dynamic> customerStop() => ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 4822,
|
||||
'orderid': 'ORDER-CX-1',
|
||||
'consignmentstatus': 'Out_for_Delivery',
|
||||
'consignmentid': 78,
|
||||
'next_action': 'deliver',
|
||||
'pickupcustomer': 'Anitha R',
|
||||
'pickupaddress': '12 Gandhipuram, Coimbatore',
|
||||
'recipientname': 'Ravi Kumar',
|
||||
'dropaddress': '4 Race Course, Coimbatore 641018',
|
||||
});
|
||||
|
||||
testWidgets('the BASE leads, not the sender he collected from', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(
|
||||
tester,
|
||||
PickupCard(
|
||||
item: hubStop(),
|
||||
displayStep: 1,
|
||||
distanceMeters: 4200,
|
||||
collectedIds: const {'ORDER-HUB-1'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('BASE HANDOVER'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Coimbatore Base'),
|
||||
findsOneWidget,
|
||||
reason: 'the rider must know WHICH base without opening a sheet',
|
||||
);
|
||||
expect(
|
||||
find.text('Anitha R'),
|
||||
findsNothing,
|
||||
reason:
|
||||
'the sender he collected from an hour ago is not the destination',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the base address is readable at a gate', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
PickupCard(
|
||||
item: hubStop(),
|
||||
displayStep: 1,
|
||||
distanceMeters: 4200,
|
||||
collectedIds: const {'ORDER-HUB-1'},
|
||||
),
|
||||
);
|
||||
|
||||
// Street and pincode, not the locality summary the other legs use —
|
||||
// "Peelamedu" alone does not find a loading bay.
|
||||
expect(
|
||||
find.textContaining('14 Avinashi Road'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('641004'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the distant receiver never takes the headline', (tester) async {
|
||||
await pump(
|
||||
tester,
|
||||
PickupCard(
|
||||
item: hubStop(),
|
||||
displayStep: 1,
|
||||
distanceMeters: 4200,
|
||||
collectedIds: const {'ORDER-HUB-1'},
|
||||
),
|
||||
);
|
||||
|
||||
// The rule that predates this change and must survive it: a Chennai
|
||||
// receiver on a parcel going to a Coimbatore base must not be drawn as the
|
||||
// place the rider is heading.
|
||||
expect(find.text('Ravi Kumar'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a base with no coordinates still names itself', (tester) async {
|
||||
// It is a caption rather than a destination — navigating to 0,0 lands in
|
||||
// the Gulf of Guinea — but the rider still needs to read which base it is.
|
||||
// The geofence refuses the handover separately, with a sentence pointing at
|
||||
// the office.
|
||||
await pump(
|
||||
tester,
|
||||
PickupCard(
|
||||
item: hubStop(hub: const {'id': 2, 'name': 'Salem Base'}),
|
||||
displayStep: 1,
|
||||
distanceMeters: 4200,
|
||||
collectedIds: const {'ORDER-HUB-1'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('BASE HANDOVER'), findsOneWidget);
|
||||
// Once — the name is the headline, and the address line stays empty rather
|
||||
// than repeating it.
|
||||
expect(find.text('Salem Base'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a customer delivery still leads with the RECIPIENT', (
|
||||
tester,
|
||||
) async {
|
||||
// The other half of the contract. This change must not push the base
|
||||
// treatment onto a leg that has a real receiver — that fix (recipient, not
|
||||
// sender) was itself a production bug once.
|
||||
await pump(
|
||||
tester,
|
||||
PickupCard(
|
||||
item: customerStop(),
|
||||
displayStep: 1,
|
||||
distanceMeters: 1400,
|
||||
collectedIds: const {'ORDER-CX-1'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('CUSTOMER DELIVERY'), findsOneWidget);
|
||||
expect(find.text('Ravi Kumar'), findsOneWidget);
|
||||
|
||||
// The sender IS still on the card, and should be: once the parcel is in the
|
||||
// box the pickup is history, but it is what establishes custody and on a
|
||||
// run with two counters it is how he knows which shelf the label came off.
|
||||
// It sits under a hairline at half the weight — so "leads" is a claim about
|
||||
// TYPE SIZE, and that is what gets asserted rather than mere presence.
|
||||
expect(find.text('Anitha R'), findsOneWidget);
|
||||
|
||||
double sizeOf(String text) =>
|
||||
tester.widget<Text>(find.text(text)).style?.fontSize ?? 0;
|
||||
|
||||
expect(
|
||||
sizeOf('Ravi Kumar'),
|
||||
greaterThan(sizeOf('Anitha R')),
|
||||
reason: 'the receiver leads; the sender is context beneath him',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('on a base leg the sender is not drawn as context either', (
|
||||
tester,
|
||||
) async {
|
||||
// The context block is gated on `dropLeads`, which is false for a base leg
|
||||
// — so a handover card carries the base and nothing that could be mistaken
|
||||
// for a destination.
|
||||
await pump(
|
||||
tester,
|
||||
PickupCard(
|
||||
item: hubStop(),
|
||||
displayStep: 1,
|
||||
distanceMeters: 4200,
|
||||
collectedIds: const {'ORDER-HUB-1'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Anitha R'), findsNothing);
|
||||
expect(find.text('Ravi Kumar'), findsNothing);
|
||||
expect(find.text('Coimbatore Base'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -56,22 +56,35 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('the mapping is per rider and per line', () async {
|
||||
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'],
|
||||
isNull,
|
||||
reason: "one line's consignment mapping is not another's",
|
||||
'46',
|
||||
reason: 'one rider, one day, one drawer — whatever he is carrying',
|
||||
);
|
||||
|
||||
SharedPreferences.setMockInitialValues({'userid': 99});
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
expect(
|
||||
(await getConsignmentIds())['ORDER-1'],
|
||||
isNull,
|
||||
reason: "another rider's handset drawer is not this one",
|
||||
reason: "another rider's handset drawer is still not this one",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,22 +27,39 @@ void main() {
|
||||
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
group('the three outcomes', () {
|
||||
test('are exactly delivered, skip and cancelled', () {
|
||||
group('the four ways a stop in custody can end', () {
|
||||
test('are delivered, skip, cancelled and handed over', () {
|
||||
// ── `handedOver` was added 2026-09-16 ──
|
||||
//
|
||||
// There used to be three, and the missing fourth is why a base-routed
|
||||
// parcel could not be closed at all: `MilerApi.inwardAtHub` existed, was
|
||||
// documented and was covered by `hub_handover_contract_test.dart`, and
|
||||
// had no call site. A rider carrying a Gandhipuram → Chennai consignment
|
||||
// was told "hand it over at base" by `releaseForDelivery` and given no
|
||||
// control that would record him doing so.
|
||||
//
|
||||
// It is deliberately NOT a kind of `delivered`: a base handover has no
|
||||
// receiver, no proof photo, no OTP and no COD, and it must never reach
|
||||
// `deliver` — that route moves the consignment to `Out_for_Delivery`
|
||||
// against a receiver in another district, which the handset cannot undo.
|
||||
expect(DeliveryOutcome.values.map((o) => o.label), [
|
||||
'Delivered',
|
||||
'Skip',
|
||||
'Cancelled',
|
||||
'Handed over',
|
||||
]);
|
||||
});
|
||||
|
||||
test('delivered is the only one that completes the order', () {
|
||||
// Skip is a return visit — it increments `attemptcount` rather than
|
||||
// failing the parcel — and cancelled is a withdrawal. Neither is a
|
||||
// delivery, and Activity must not file them as one.
|
||||
// failing the parcel — and cancelled is a withdrawal. Handed over ends
|
||||
// this RIDER's custody while the parcel's own journey carries on inside
|
||||
// the network. None of the three is a delivery, and Activity must not
|
||||
// file them as one.
|
||||
expect(DeliveryOutcome.delivered.pastTense, 'Delivered');
|
||||
expect(DeliveryOutcome.skipped.pastTense, 'Skipped');
|
||||
expect(DeliveryOutcome.cancelled.pastTense, 'Cancelled');
|
||||
expect(DeliveryOutcome.handedOver.pastTense, 'Handed over');
|
||||
});
|
||||
|
||||
test('each one is coloured by what it means, not by where it sits', () {
|
||||
|
||||
@@ -141,10 +141,16 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('the other line cannot either', () async {
|
||||
test('but the same rider can, whatever line he is reading', () async {
|
||||
// Reversed 2026-09-16 with the removal of the line from [WorkScope].
|
||||
// A doorstep photo is evidence the rider produced; hiding it from him
|
||||
// because a tenant label resolved differently after a refresh is how a
|
||||
// proof goes missing at exactly the moment somebody disputes a delivery.
|
||||
//
|
||||
// The ownership rule above is untouched and is the one that matters.
|
||||
await ProofStore.save('ORDER-1', (await cameraShot('a')).path);
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(await ProofStore.pathFor('ORDER-1'), isNull);
|
||||
expect(await ProofStore.pathFor('ORDER-1'), isNotNull);
|
||||
});
|
||||
|
||||
test('logout deletes the photos, not just the index', () async {
|
||||
|
||||
@@ -1,67 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/controllers/pickups_controller.dart';
|
||||
import 'package:miler/data/geofence.dart';
|
||||
|
||||
/// Whichever way the proximity fence is set, it must be set **once** and on
|
||||
/// purpose.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE SWITCH, AND THE FOUR SHAPES THAT MUST NOT COME BACK
|
||||
///
|
||||
/// [kGeofenceEnforced] decides whether "Picked up" means the rider was standing
|
||||
/// at the door or merely pressed a button, and it has already been wrong in
|
||||
/// three directions: it was `kDebugMode` — so every debug build had no fence
|
||||
/// and the fence was therefore never tested — then a hard `false`, so the flow
|
||||
/// could not be walked at a desk at all, then a define defaulting to off.
|
||||
/// This file used to assert the opposite of what it asserts now, and that is
|
||||
/// the point of keeping it rather than deleting it.
|
||||
///
|
||||
/// **It is OFF, decided 2026-08-25.** It was turned on that morning with a
|
||||
/// radius of [kGeofenceRadiusMeters] and turned off again the same day, at the
|
||||
/// founder's call. Nothing was found wrong with it — this is a decision about
|
||||
/// *when* to switch it on.
|
||||
/// It read:
|
||||
///
|
||||
/// So the direction this file guards has flipped twice in a day, which is
|
||||
/// exactly the pattern that made the old defaults dangerous, and it is why the
|
||||
/// three tests below are not all about the direction. The measurement work is
|
||||
/// unchanged and still asserted: a `best` fix with a shelf life, the phone's
|
||||
/// error credited to the rider, and one radius rather than three. All of it is
|
||||
/// dormant while [kGeofenceEnforced] is false and none of it needs revisiting
|
||||
/// on the day it goes back on.
|
||||
/// expect(kGeofenceEnforced, isFalse); // "recorded decision", 2026-08-25
|
||||
/// expect(kGeofenceRadiusMeters, 10);
|
||||
///
|
||||
/// So the test suite was **enforcing the release blocker**: a developer who
|
||||
/// turned the fence on to fix it would be met with a red test telling him the
|
||||
/// off state was deliberate. Between them those two lines meant every shipped
|
||||
/// build let a rider mark a stop picked up from anywhere on earth, and the
|
||||
/// suite agreed that was correct.
|
||||
///
|
||||
/// Reversed 2026-09-16 as part of the Play Store release audit. The fence now
|
||||
/// lives in `lib/data/geofence.dart` and the behaviour is exercised properly in
|
||||
/// `geofence_test.dart` — 41 cases, the full distance table, every failure
|
||||
/// mode, and an HTTP client that fails the test if a blocked rung posts
|
||||
/// anything.
|
||||
///
|
||||
/// What is left here is the short list of *specific past mistakes*, each of
|
||||
/// which shipped, so that none of them can return quietly.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
test('there is one switch, and the legacy name derives from it', () {
|
||||
// Two constants that could disagree is exactly how a build ends up with
|
||||
// the fence on in one gate and off in the other — the bulk check on Home
|
||||
// and the per-stop check in the controller are separate code paths that
|
||||
// must never answer differently.
|
||||
expect(kBypassGeofenceForTesting, !kGeofenceEnforced);
|
||||
});
|
||||
|
||||
test('enforcement is off, and that is the recorded decision', () {
|
||||
// If this fails, one of two things happened, and both should be read rather
|
||||
// than silenced:
|
||||
//
|
||||
// • the default was turned back on — record the date and the reason here,
|
||||
// as the previous flips did; or
|
||||
// • this run passed `--dart-define=ENFORCE_GEOFENCE=true`, in which case
|
||||
// the failure is correct and is telling you this build has the fence on.
|
||||
test('MISTAKE 1 — the fence must not default to off', () {
|
||||
// `bool.fromEnvironment('ENFORCE_GEOFENCE', defaultValue: false)`. Every
|
||||
// `flutter build appbundle --release` passes no dart-define, so the default
|
||||
// *is* the shipped behaviour. It had been `kDebugMode` before that — off in
|
||||
// exactly the builds anyone tested with — and a hard `false` before that.
|
||||
// Three spellings, one effect.
|
||||
expect(
|
||||
kGeofenceEnforced,
|
||||
isFalse,
|
||||
isTrue,
|
||||
reason:
|
||||
'Proximity enforcement is expected to be OFF (see kGeofenceEnforced). '
|
||||
'Either the default was restored, or this run passed '
|
||||
'--dart-define=ENFORCE_GEOFENCE=true.',
|
||||
'The release build must enforce proximity. If this run passed '
|
||||
'--dart-define=ENFORCE_GEOFENCE=false, this failure is correct.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the radius is one number, and it is the tight one', () {
|
||||
// Asserted while the fence is off, deliberately. The app used to hold
|
||||
// three — a configured `pickupradius` defaulting to 100 in the controller,
|
||||
// a hardcoded 500 in Home's bulk gate, and no agreement between them — so
|
||||
// which fence a rider met depended on whether he ticked boxes or slid a
|
||||
// sheet. Whichever way the switch above is set, that must not come back.
|
||||
expect(kGeofenceRadiusMeters, 10);
|
||||
test('MISTAKE 2 — the radius must not be smaller than the GPS', () {
|
||||
// 10 m is at or inside the error radius of consumer GPS, so the fence
|
||||
// stopped measuring proximity and started measuring whether the satellites
|
||||
// were kind. It refused riders standing at doors, which is what got the
|
||||
// whole control switched off twice.
|
||||
expect(kGeofenceRadiusMetres, 100);
|
||||
expect(
|
||||
kGeofenceRadiusMetres,
|
||||
greaterThan(kGeofenceMaxAccuracyMetres),
|
||||
reason: 'a fence smaller than the fix that measures it cannot be met',
|
||||
);
|
||||
});
|
||||
|
||||
test('a cached fix has a shelf life', () {
|
||||
// On a round, a stale position is reliably the *previous* stop. Anything
|
||||
// much longer than this and the fence starts measuring from the last door.
|
||||
test('MISTAKE 3 — there must be exactly one radius', () {
|
||||
// The app once held three at the same time: a per-tenant `pickupradius`
|
||||
// from prefs defaulting to 100, a hardcoded 500 in Home's bulk gate, and 10
|
||||
// in the controller. Which fence a rider met depended on whether he ticked
|
||||
// boxes on Home or slid the sheet on a stop.
|
||||
//
|
||||
// Both gates call `Geofence.check` now and neither holds arithmetic, so
|
||||
// this asserts the constant is the only knob there is to turn.
|
||||
expect(kGeofenceRadiusMetres, isA<double>());
|
||||
expect(kGeofenceRadiusMetres, greaterThan(0));
|
||||
});
|
||||
|
||||
test('MISTAKE 4 — a cached fix must have a shelf life', () {
|
||||
// A last-known position is instant, free, and can be an hour old. On a
|
||||
// round it is reliably the *previous* stop, which is how a rider marks a
|
||||
// delivery arrived from the last street he was on.
|
||||
expect(kGeofenceFixMaxAge, const Duration(seconds: 30));
|
||||
});
|
||||
|
||||
test('the fence still has a way to be switched off in an emergency', () {
|
||||
// Deliberately kept, and deliberately not the default. If the fence starts
|
||||
// refusing real work at real doors, `--dart-define=ENFORCE_GEOFENCE=false`
|
||||
// gets the fleet moving inside one release rather than one sprint — and
|
||||
// every bypassed check logs in every build mode, so a build's own log says
|
||||
// which way it was compiled.
|
||||
//
|
||||
// Raise `kGeofenceRadiusMetres` before reaching for it.
|
||||
expect(
|
||||
const bool.fromEnvironment('ENFORCE_GEOFENCE', defaultValue: true),
|
||||
kGeofenceEnforced,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
588
test/geofence_test.dart
Normal file
588
test/geofence_test.dart
Normal file
@@ -0,0 +1,588 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/controllers/pickups_controller.dart';
|
||||
import 'package:miler/data/geofence.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE PROXIMITY FENCE
|
||||
///
|
||||
/// This is the control that decides whether "Picked up" means the rider was
|
||||
/// standing at the door or merely pressed a button, and it has been wrong in
|
||||
/// four directions inside one year: compiled off by default, set to a 10 m
|
||||
/// radius no consumer GPS can resolve, failing **open** on any thrown
|
||||
/// exception, and crediting the phone's own error to the rider so that a vague
|
||||
/// fix bought slack rather than a refusal.
|
||||
///
|
||||
/// So the tests below are not a spot check of the arithmetic. They pin, in
|
||||
/// order:
|
||||
///
|
||||
/// 1. the **switch** — on by default, because that default has flipped twice;
|
||||
/// 2. the **radius** — 100 m, inclusive at the boundary;
|
||||
/// 3. the **distance table** the spec asks for, 0 m to 250 m;
|
||||
/// 4. every way of **failing to measure**, each of which must refuse;
|
||||
/// 5. the one that matters most — **a blocked rung sends no HTTP request.**
|
||||
///
|
||||
/// (5) is the whole point. A fence that shows a message and posts the status
|
||||
/// anyway is worse than no fence: the hub is told the rider was there *and*
|
||||
/// the app claims to have checked.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A stop in Coimbatore. Any point would do; a real one keeps the numbers
|
||||
/// recognisable when a failure prints them.
|
||||
const double kTargetLat = 11.0272;
|
||||
const double kTargetLng = 76.9905;
|
||||
|
||||
/// Metres per degree of latitude. Constant enough for a fixture — the tests
|
||||
/// assert against what [Geolocator.distanceBetween] actually measures, not
|
||||
/// against this, so the approximation cannot make a test pass wrongly.
|
||||
const double _metresPerDegreeLat = 111320.0;
|
||||
|
||||
/// A rider fix [metres] due north of the stop.
|
||||
Position riderAt(
|
||||
double metres, {
|
||||
double accuracy = 8,
|
||||
Duration age = Duration.zero,
|
||||
}) => Position(
|
||||
latitude: kTargetLat + (metres / _metresPerDegreeLat),
|
||||
longitude: kTargetLng,
|
||||
timestamp: DateTime.now().subtract(age),
|
||||
accuracy: accuracy,
|
||||
altitude: 0,
|
||||
altitudeAccuracy: 0,
|
||||
heading: 0,
|
||||
headingAccuracy: 0,
|
||||
speed: 0,
|
||||
speedAccuracy: 0,
|
||||
);
|
||||
|
||||
void useFix(Position p) =>
|
||||
Geofence.positionProvider = () async => GeofenceFix(position: p);
|
||||
|
||||
void useFailure(GeofenceOutcome outcome) =>
|
||||
Geofence.positionProvider = () async => GeofenceFix(failure: outcome);
|
||||
|
||||
void main() {
|
||||
tearDown(Geofence.useDevice);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
group('the switch and the radius', () {
|
||||
test('enforcement is ON by default', () {
|
||||
// It was `defaultValue: false`, so every release build shipped with the
|
||||
// fence returning true on its third line. If this fails, either the
|
||||
// default was reverted — which is the release blocker this file exists to
|
||||
// prevent — or the run passed `--dart-define=ENFORCE_GEOFENCE=false`, in
|
||||
// which case the failure is correct and is telling you so.
|
||||
expect(
|
||||
kGeofenceEnforced,
|
||||
isTrue,
|
||||
reason:
|
||||
'Proximity enforcement must be ON by default. Either the default '
|
||||
'was reverted, or this run passed ENFORCE_GEOFENCE=false.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the radius is 100 metres, and there is only one of them', () {
|
||||
// The app once held three: a configured `pickupradius` defaulting to 100,
|
||||
// a hardcoded 500 in Home's bulk gate, and 10 in the controller. Which
|
||||
// fence a rider met depended on whether he ticked boxes or slid a sheet.
|
||||
expect(kGeofenceRadiusMetres, 100);
|
||||
});
|
||||
|
||||
test('one fix may serve a batch, but only briefly', () {
|
||||
// [kGeofenceFixReuse] is what makes a twenty-order bulk action cost one
|
||||
// GPS settle instead of twenty. It is a performance change to a safety
|
||||
// control, so the two bounds that keep it honest are asserted here.
|
||||
expect(
|
||||
kGeofenceFixReuse,
|
||||
lessThanOrEqualTo(kGeofenceFixMaxAge),
|
||||
reason:
|
||||
'the reuse window must sit inside the staleness rule — a fix too '
|
||||
'old to measure with cannot become acceptable by being cached',
|
||||
);
|
||||
// At walking pace (~1.4 m/s) the window is ~20 m of possible drift
|
||||
// against a 100 m fence. Riding away covers the radius inside it, so a
|
||||
// rider who genuinely leaves is refused on the next real fix rather than
|
||||
// carried to the next door by the cached one.
|
||||
expect(
|
||||
kGeofenceFixReuse.inSeconds * 1.4,
|
||||
lessThan(kGeofenceRadiusMetres / 2),
|
||||
reason: 'drift during the window must stay well under the radius',
|
||||
);
|
||||
});
|
||||
|
||||
test('resetting the cache is available to sign-out and tests', () {
|
||||
// A cached position is a fact about a rider. It must not outlive him on a
|
||||
// shared handset.
|
||||
Geofence.resetCache();
|
||||
Geofence.useDevice();
|
||||
});
|
||||
|
||||
test('a cached fix has a shelf life, and a vague one is rejected', () {
|
||||
expect(kGeofenceFixMaxAge, const Duration(seconds: 30));
|
||||
expect(
|
||||
kGeofenceMaxAccuracyMetres,
|
||||
lessThan(kGeofenceRadiusMetres),
|
||||
reason:
|
||||
'a fix whose error is as large as the fence cannot resolve it, and '
|
||||
'must be refused rather than credited',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
group('the distance table', () {
|
||||
// Every case the spec names. Each asserts the *rule* against what
|
||||
// Geolocator actually measured, so a fixture that drifts by a metre cannot
|
||||
// quietly invert a boundary case.
|
||||
for (final metres in const [0.0, 25.0, 50.0, 99.0, 100.0, 101.0, 125.0,
|
||||
250.0]) {
|
||||
test('${metres.round()} m', () async {
|
||||
final pos = riderAt(metres);
|
||||
useFix(pos);
|
||||
|
||||
final measured = Geofence.distanceBetween(
|
||||
kTargetLat,
|
||||
kTargetLng,
|
||||
pos.latitude,
|
||||
pos.longitude,
|
||||
);
|
||||
expect(
|
||||
measured,
|
||||
closeTo(metres, 1.0),
|
||||
reason: 'fixture should sit where it claims',
|
||||
);
|
||||
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
|
||||
final shouldAllow = measured <= kGeofenceRadiusMetres;
|
||||
expect(
|
||||
d.allowed,
|
||||
shouldAllow,
|
||||
reason:
|
||||
'at ${measured.toStringAsFixed(1)} m against a '
|
||||
'${kGeofenceRadiusMetres.round()} m fence',
|
||||
);
|
||||
expect(
|
||||
d.outcome,
|
||||
shouldAllow ? GeofenceOutcome.inside : GeofenceOutcome.outside,
|
||||
);
|
||||
expect(d.distanceMetres, closeTo(measured, 0.001));
|
||||
expect(d.accuracyMetres, 8);
|
||||
expect(d.timestamp, isNotNull);
|
||||
});
|
||||
}
|
||||
|
||||
test('the boundary is inclusive — 100 m is in, a hair over is out', () {
|
||||
// Stated as arithmetic rather than as a fixture, because this is the one
|
||||
// place a `<` for a `<=` changes a rider's day and no GPS fixture can be
|
||||
// placed accurately enough to catch it.
|
||||
expect(99.999 <= kGeofenceRadiusMetres, isTrue);
|
||||
expect(100.0 <= kGeofenceRadiusMetres, isTrue);
|
||||
expect(100.001 <= kGeofenceRadiusMetres, isFalse);
|
||||
});
|
||||
|
||||
test('a refusal tells the rider how far, and in a unit he uses', () async {
|
||||
useFix(riderAt(240));
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.reason, contains('240 m'));
|
||||
expect(d.reason, contains('100 m'));
|
||||
expect(d.reason, contains('arrived'));
|
||||
// Never a developer error, a status code or a coordinate pair.
|
||||
expect(d.reason, isNot(contains('Exception')));
|
||||
expect(d.reason, isNot(contains('null')));
|
||||
});
|
||||
|
||||
test('over a kilometre reads in kilometres', () async {
|
||||
useFix(riderAt(4200));
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Delivered',
|
||||
);
|
||||
expect(d.reason, contains('4.2 km'));
|
||||
expect(d.reason, contains('delivered'));
|
||||
});
|
||||
|
||||
test('the verb follows the rung', () async {
|
||||
useFix(riderAt(500));
|
||||
for (final action in const [
|
||||
'Arrived',
|
||||
'Picked',
|
||||
'Picked up',
|
||||
'Delivery arrived',
|
||||
'Delivered',
|
||||
'Handed over',
|
||||
]) {
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: action,
|
||||
);
|
||||
expect(d.allowed, isFalse, reason: action);
|
||||
expect(d.reason, contains(action.toLowerCase()), reason: action);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
group('every way of failing to measure is a refusal', () {
|
||||
// The old implementation allowed on four of these six. That is what made
|
||||
// the fence decorative: switching location off opened every rung.
|
||||
|
||||
test('GPS switched off', () async {
|
||||
useFailure(GeofenceOutcome.serviceDisabled);
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.serviceDisabled);
|
||||
expect(d.reason, contains('Location is switched off'));
|
||||
});
|
||||
|
||||
test('permission denied', () async {
|
||||
useFailure(GeofenceOutcome.permissionDenied);
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Delivered',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.permissionDenied);
|
||||
expect(d.reason, contains('Allow location access'));
|
||||
});
|
||||
|
||||
test('permission denied forever points at Settings', () async {
|
||||
useFailure(GeofenceOutcome.permissionDeniedForever);
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.permissionDeniedForever);
|
||||
expect(d.reason, contains('Settings'));
|
||||
});
|
||||
|
||||
test('a timeout waiting for the fix', () async {
|
||||
useFailure(GeofenceOutcome.timeout);
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.timeout);
|
||||
});
|
||||
|
||||
test('a stale fix is not a measurement', () async {
|
||||
// On a round, a stale position is reliably the *previous* stop — which is
|
||||
// how a rider marks a delivery arrived from the last street.
|
||||
useFailure(GeofenceOutcome.staleFix);
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.staleFix);
|
||||
});
|
||||
|
||||
test('a fix too vague to resolve the fence is refused, not credited',
|
||||
() async {
|
||||
// The inversion this replaces: the old check was
|
||||
// `distance - accuracy > radius`, so a ±250 m fix bought 250 m of slack.
|
||||
// The worse the fix, the easier it was to pass. Here the rider is 10 m
|
||||
// away — comfortably inside — and is still refused, because the phone
|
||||
// cannot show that he is.
|
||||
useFix(riderAt(10, accuracy: kGeofenceMaxAccuracyMetres + 1));
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.poorAccuracy);
|
||||
expect(d.reason, contains('Step into the open'));
|
||||
expect(d.accuracyMetres, kGeofenceMaxAccuracyMetres + 1);
|
||||
});
|
||||
|
||||
test('a vague fix cannot pass even when it is standing on the stop',
|
||||
() async {
|
||||
useFix(riderAt(0, accuracy: 500));
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Delivered',
|
||||
);
|
||||
expect(d.allowed, isFalse, reason: 'distance 0 must not beat accuracy');
|
||||
});
|
||||
|
||||
test('a fix at the accuracy ceiling is still usable', () async {
|
||||
useFix(riderAt(20, accuracy: kGeofenceMaxAccuracyMetres));
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isTrue, reason: 'the ceiling is inclusive');
|
||||
});
|
||||
|
||||
test('the provider throwing is a refusal, not a pass', () async {
|
||||
// This is the `catch { return true; // fail-safe }` that used to open
|
||||
// every rung in the app on any throw from the location stack.
|
||||
Geofence.positionProvider = () async => throw Exception('platform');
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.error);
|
||||
expect(d.reason, isNotNull);
|
||||
expect(d.reason, isNot(contains('platform')),
|
||||
reason: 'the rider must not be shown the exception');
|
||||
});
|
||||
|
||||
test('a rider fix of 0,0 is absent, not the Gulf of Guinea', () async {
|
||||
useFix(
|
||||
Position(
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 5,
|
||||
altitude: 0,
|
||||
altitudeAccuracy: 0,
|
||||
heading: 0,
|
||||
headingAccuracy: 0,
|
||||
speed: 0,
|
||||
speedAccuracy: 0,
|
||||
),
|
||||
);
|
||||
final d = await Geofence.check(
|
||||
targetLat: kTargetLat,
|
||||
targetLng: kTargetLng,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.noFix);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
group('a stop with no coordinates', () {
|
||||
// This used to return true — "Missing coordinates. Proceeding with update."
|
||||
// A booking with a blank latitude therefore opened every rung on that stop,
|
||||
// which is the bypass that makes a fence decorative.
|
||||
for (final pair in const [
|
||||
(null, null, 'both null'),
|
||||
(0.0, 0.0, 'both zero'),
|
||||
(kTargetLat, 0.0, 'longitude zero'),
|
||||
(0.0, kTargetLng, 'latitude zero'),
|
||||
(95.0, kTargetLng, 'latitude out of range'),
|
||||
(kTargetLat, 200.0, 'longitude out of range'),
|
||||
]) {
|
||||
test('is refused — ${pair.$3}', () async {
|
||||
useFix(riderAt(0));
|
||||
final d = await Geofence.check(
|
||||
targetLat: pair.$1,
|
||||
targetLng: pair.$2,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(d.allowed, isFalse);
|
||||
expect(d.outcome, GeofenceOutcome.noTarget);
|
||||
// The rider cannot fix the hub's data from a doorstep, so the sentence
|
||||
// sends him to the people who can.
|
||||
expect(d.reason, contains('office'));
|
||||
});
|
||||
}
|
||||
|
||||
test('it does not even ask the phone for a fix', () async {
|
||||
// An 8-second GPS settle to answer a question with no target is time
|
||||
// taken off a rider's round for nothing.
|
||||
var asked = false;
|
||||
Geofence.positionProvider = () async {
|
||||
asked = true;
|
||||
return GeofenceFix(position: riderAt(0));
|
||||
};
|
||||
await Geofence.check(
|
||||
targetLat: null,
|
||||
targetLng: null,
|
||||
action: 'Arrived',
|
||||
);
|
||||
expect(asked, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
group('BLOCKED MEANS NO REQUEST IS SENT', () {
|
||||
// The rule the whole control rests on. Every one of these drives the real
|
||||
// `PickupsController` rung with an HTTP client that fails the test if it is
|
||||
// ever called — so this asserts the *call sites*, not the policy object.
|
||||
|
||||
late PickupsController controller;
|
||||
late List<String> sent;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||||
sent = <String>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add('${req.method} ${req.url.path}');
|
||||
// A realistic `reached` reply, in the shape production actually
|
||||
// returns — `success` is the envelope key (`ApiConfig.toLegacyEnvelope`
|
||||
// reads it; `status` is ignored), and the arrival stamp sits inside
|
||||
// `data`. A bare 200 is deliberately NOT read as a transition (see
|
||||
// `MilerLifecycle.reached`), so a thin fixture would fail the positive
|
||||
// case below for a reason that has nothing to do with the fence.
|
||||
return http.Response(
|
||||
'{"success":true,"data":{"bookingid":4821,'
|
||||
'"status":"Miler_Assigned","reachedat":"2026-09-16T10:14:02Z"}}',
|
||||
200,
|
||||
);
|
||||
});
|
||||
controller = PickupsController();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
MilerApi.client = http.Client();
|
||||
Geofence.useDevice();
|
||||
});
|
||||
|
||||
test('Arrived, 250 m away — no request', () async {
|
||||
useFix(riderAt(250));
|
||||
final ok = await controller.updateArrivedStatus(
|
||||
pickupId: 4821,
|
||||
orderHeaderId: 4821,
|
||||
pickupLat: '$kTargetLat',
|
||||
pickupLng: '$kTargetLng',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty, reason: 'the fence refused; nothing may be posted');
|
||||
expect(controller.lastBlockedReason, contains('Move within 100 m'));
|
||||
});
|
||||
|
||||
test('Arrived with GPS off — no request', () async {
|
||||
useFailure(GeofenceOutcome.serviceDisabled);
|
||||
final ok = await controller.updateArrivedStatus(
|
||||
pickupId: 4821,
|
||||
orderHeaderId: 4821,
|
||||
pickupLat: '$kTargetLat',
|
||||
pickupLng: '$kTargetLng',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('Arrived with permission denied forever — no request', () async {
|
||||
useFailure(GeofenceOutcome.permissionDeniedForever);
|
||||
final ok = await controller.updateArrivedStatus(
|
||||
pickupId: 4821,
|
||||
orderHeaderId: 4821,
|
||||
pickupLat: '$kTargetLat',
|
||||
pickupLng: '$kTargetLng',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('Arrived on a stop with no pin — no request', () async {
|
||||
useFix(riderAt(0));
|
||||
final ok = await controller.updateArrivedStatus(
|
||||
pickupId: 4821,
|
||||
orderHeaderId: 4821,
|
||||
pickupLat: '0',
|
||||
pickupLng: '0',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(
|
||||
sent,
|
||||
isEmpty,
|
||||
reason:
|
||||
'a booking with no coordinates used to be waved through — that is '
|
||||
'the bypass that made the fence decorative',
|
||||
);
|
||||
});
|
||||
|
||||
test('Delivery arrived, 250 m away — no request', () async {
|
||||
useFix(riderAt(250));
|
||||
final ok = await controller.updateDeliveryArrived(
|
||||
pickupId: 4821,
|
||||
dropLat: '$kTargetLat',
|
||||
dropLng: '$kTargetLng',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('Delivery arrived with NO drop pin — no request', () async {
|
||||
// The conditional that used to wrap this call site —
|
||||
// `if (dLat != 0 && dLng != 0)` — skipped the fence silently for exactly
|
||||
// the stops where nobody could say afterwards where the rider was.
|
||||
useFix(riderAt(0));
|
||||
final ok = await controller.updateDeliveryArrived(
|
||||
pickupId: 4821,
|
||||
dropLat: '0',
|
||||
dropLng: '0',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('Delivered, 250 m away — no request', () async {
|
||||
useFix(riderAt(250));
|
||||
final ok = await controller.updateDeliveredStatus(
|
||||
pickupId: 4821,
|
||||
consignmentId: '77',
|
||||
deliveredToName: 'Anitha R',
|
||||
dropLat: '$kTargetLat',
|
||||
dropLng: '$kTargetLng',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('Delivered with NO drop pin — no request', () async {
|
||||
useFix(riderAt(0));
|
||||
final ok = await controller.updateDeliveredStatus(
|
||||
pickupId: 4821,
|
||||
consignmentId: '77',
|
||||
deliveredToName: 'Anitha R',
|
||||
dropLat: '0',
|
||||
dropLng: '0',
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
|
||||
test('inside the fence, the request DOES go out', () async {
|
||||
// The other half of the contract, and the reason this group is not just
|
||||
// an assertion that everything is broken.
|
||||
useFix(riderAt(40));
|
||||
final ok = await controller.updateArrivedStatus(
|
||||
pickupId: 4821,
|
||||
orderHeaderId: 4821,
|
||||
pickupLat: '$kTargetLat',
|
||||
pickupLng: '$kTargetLng',
|
||||
);
|
||||
expect(ok, isTrue);
|
||||
expect(sent, isNotEmpty);
|
||||
expect(sent.single, contains('/miler/bookings/4821/reached'));
|
||||
expect(controller.lastBlockedReason, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
249
test/hub_handover_action_test.dart
Normal file
249
test/hub_handover_action_test.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
185
test/logout_clears_session_test.dart
Normal file
185
test/logout_clears_session_test.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/mutation_guard.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// LOGGING OUT ENDS THE SESSION, NOT JUST THE ROUTE
|
||||
///
|
||||
/// The logout handler cleared the scoped stores and the doorstep photos, wrote
|
||||
/// `logged_out = true`, and sent the rider to the sign-in screen. It did **not**
|
||||
/// clear the bearer token. So the app *looked* signed out — the flag kept him
|
||||
/// on the sign-in screen — while `authtoken` sat in SharedPreferences, still
|
||||
/// valid, until the next `verifyPinWithServer` happened to overwrite it.
|
||||
///
|
||||
/// Two consequences, and the second is the one that matters on a shared
|
||||
/// handset:
|
||||
///
|
||||
/// * anything that reads the token without consulting the flag — a background
|
||||
/// isolate, the notification handler, a heartbeat that outlives the route
|
||||
/// change — could keep making authenticated calls as the rider who left;
|
||||
/// * a phone handed to the next rider carried the previous one's credential on
|
||||
/// disk.
|
||||
///
|
||||
/// Two tests, because the bug had two halves: the credential lifecycle (does
|
||||
/// clearing actually clear?) and the call site (does logout actually call it?).
|
||||
/// The first would have passed throughout — `clearToken` was never broken,
|
||||
/// nobody called it — which is exactly why the second one reads the source.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('the credential lifecycle', () {
|
||||
test('a cleared token does not come back', () async {
|
||||
SharedPreferences.setMockInitialValues({'authtoken': 'rider-38-session'});
|
||||
expect(await ApiConfig.getToken(), 'rider-38-session');
|
||||
|
||||
await ApiConfig.clearToken();
|
||||
|
||||
expect(
|
||||
await ApiConfig.getToken(),
|
||||
isNull,
|
||||
reason: 'the next rider on this handset must start with no session',
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty token reads as no token, not as a session', () async {
|
||||
// `getToken` returns null for '' deliberately: a blank string is truthy
|
||||
// enough to pass a `!= null` check, and that is how a signed-out app ends
|
||||
// up sending `Authorization: Bearer `.
|
||||
SharedPreferences.setMockInitialValues({'authtoken': ''});
|
||||
expect(await ApiConfig.getToken(), isNull);
|
||||
});
|
||||
|
||||
test('clearing an already-clear session is not an error', () async {
|
||||
// Logout runs it unconditionally, including on a handset that never
|
||||
// signed in — a crash there would strand the rider on the account screen.
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
await ApiConfig.clearToken();
|
||||
expect(await ApiConfig.getToken(), isNull);
|
||||
});
|
||||
|
||||
test('the in-flight guard does not survive the session', () async {
|
||||
// A mutation still held when the rider signs out would have its key
|
||||
// dropped for the *next* session, silently refusing his first press.
|
||||
await MutationGuard.run('accept:1042', () async {
|
||||
expect(MutationGuard.isBusy('accept:1042'), isTrue);
|
||||
return true;
|
||||
});
|
||||
MutationGuard.reset();
|
||||
expect(MutationGuard.isBusy('accept:1042'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('the teardown itself', () {
|
||||
// Read from source. The steps are a sequence of calls with no return value
|
||||
// to assert on, and the regression class is a *missing call* — the cheapest
|
||||
// honest guard against a missing call is to look for it.
|
||||
//
|
||||
// These used to read Profilepage.dart. The teardown moved into
|
||||
// `endSession` when the 401 path needed to perform exactly the same steps;
|
||||
// the assertions followed it rather than being deleted.
|
||||
final source = File('lib/data/session.dart').readAsStringSync();
|
||||
|
||||
test('it clears the bearer token', () {
|
||||
expect(
|
||||
source,
|
||||
contains('ApiConfig.clearToken()'),
|
||||
reason:
|
||||
'Ending a session must end the credential, not only the route. '
|
||||
'Without this the token outlives the rider on a shared handset.',
|
||||
);
|
||||
});
|
||||
|
||||
test('it clears the scoped stores and the doorstep photos', () {
|
||||
expect(source, contains('clearScopedStores()'));
|
||||
expect(source, contains('ProofStore.clearScope()'));
|
||||
});
|
||||
|
||||
test('it raises the flag the launch path reads', () {
|
||||
// `app_bootstrap` and `main` both branch on this to decide the start
|
||||
// screen. Clearing the token without it would send a rider to the
|
||||
// dashboard with no credential.
|
||||
expect(source, contains("setBool('logged_out', true)"));
|
||||
});
|
||||
|
||||
test('it frees any mutation still holding a key', () {
|
||||
expect(source, contains('MutationGuard.reset()'));
|
||||
});
|
||||
|
||||
test('it drops the cached GPS fix', () {
|
||||
// [Geofence] reuses one live fix across a batch. A position is a fact
|
||||
// about a rider; the next person on this handset must not have his first
|
||||
// proximity check measured from where the last one was standing.
|
||||
expect(source, contains('Geofence.resetCache()'));
|
||||
});
|
||||
});
|
||||
|
||||
group('both ways out of a session use it', () {
|
||||
// ── The half that was missing entirely ──
|
||||
//
|
||||
// A session ends two ways: the rider presses Log out, or the server stops
|
||||
// accepting his token. Only the first was implemented.
|
||||
//
|
||||
// `MilerApi` drops the token on any 401 and calls `onUnauthorized` — and
|
||||
// NOTHING EVER ASSIGNED IT. Three references in the whole codebase, all
|
||||
// inside miler_api.dart, zero assignments. So the credential was deleted
|
||||
// and nothing else was: the profile stayed on disk, `logged_out` stayed
|
||||
// false, and the app kept drawing a signed-in dashboard whose every call
|
||||
// came back `401 authorization header is required`.
|
||||
//
|
||||
// Found on a real handset — rider 23, name and tenant on screen, no
|
||||
// `authtoken` in SharedPreferences, Home / Deliveries / Activity and the
|
||||
// heartbeat all failing in a loop. The rider sees no jobs and concludes
|
||||
// there is no work today; nothing anywhere tells him to sign in again.
|
||||
|
||||
test('the manual Log out delegates to the shared teardown', () {
|
||||
final source = File(
|
||||
'lib/views/Dashboard/profile/Profilepage.dart',
|
||||
).readAsStringSync();
|
||||
expect(
|
||||
source,
|
||||
contains('endSession()'),
|
||||
reason: 'Log out must use the same teardown the 401 path uses, or the '
|
||||
'two drift and one of them stops clearing something.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the 401 handler is actually assigned', () {
|
||||
// The assertion that would have caught the shipped bug. `onUnauthorized`
|
||||
// being declared and called proves nothing — it was both, and null.
|
||||
final source = File('lib/main.dart').readAsStringSync();
|
||||
expect(
|
||||
source,
|
||||
contains('MilerApi.onUnauthorized ='),
|
||||
reason:
|
||||
'MilerApi calls onUnauthorized on every 401. Left unassigned, a '
|
||||
'rider whose token expires is dropped into a session that looks '
|
||||
'signed in and can never make a successful call again.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the 401 handler ends the session and opens sign-in', () {
|
||||
final source = File('lib/main.dart').readAsStringSync();
|
||||
expect(source, contains('endSession()'));
|
||||
expect(
|
||||
source,
|
||||
contains('SignIn()'),
|
||||
reason: 'clearing the credential without moving the rider leaves him '
|
||||
'on a dashboard that cannot load anything',
|
||||
);
|
||||
});
|
||||
|
||||
test('a burst of 401s tears down once, not once per call', () {
|
||||
// A 401 rarely arrives alone: the home poll, the deliveries queue and the
|
||||
// heartbeat can each get one within the same second. Without a latch the
|
||||
// rider is pushed at the sign-in screen three times, and GetX stacks
|
||||
// three routes he then has to dismiss.
|
||||
final source = File('lib/main.dart').readAsStringSync();
|
||||
expect(source, contains('_signingOut'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -527,16 +527,22 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('the geofence bypass does not stop the write leaving', () async {
|
||||
// The suspicion this rules out. `_checkGeofence` returns true early when
|
||||
// enforcement is off and its success branch contains nothing else — so
|
||||
// the bypass skips the distance *rejection* and nothing more.
|
||||
test('the provider itself holds no proximity gate', () async {
|
||||
// ── What this test used to say ──
|
||||
//
|
||||
// Asserted through the provider rather than by reading the flag, because
|
||||
// "the request went out with the fence off" is the claim, and the flag is
|
||||
// only evidence for it.
|
||||
expect(kBypassGeofenceForTesting, isTrue);
|
||||
|
||||
// `expect(kBypassGeofenceForTesting, isTrue)` — it asserted the fence was
|
||||
// OFF, and then that the write still went out. The flag is gone: the
|
||||
// fence is enforced by default now and lives in `lib/data/geofence.dart`.
|
||||
//
|
||||
// The claim underneath it survives and is worth keeping: **the fence is
|
||||
// in the controller, not in the transport.** `UpdatePickupProvider` maps
|
||||
// a legacy payload onto a v1 route and posts it, full stop. If a
|
||||
// proximity check ever appears down here there would be two fences again,
|
||||
// reachable by different paths, which is the shape that gave the app a
|
||||
// 500 m radius on Home and a 10 m one on a stop.
|
||||
//
|
||||
// The controller-level rule — blocked means no request — is asserted
|
||||
// end-to-end in `geofence_test.dart`.
|
||||
await UpdatePickupProvider().updatePickup({
|
||||
'pickupid': 4211,
|
||||
'orderstatus': 'arrived',
|
||||
@@ -544,7 +550,7 @@ void main() {
|
||||
'riderslon': '76.955800',
|
||||
});
|
||||
|
||||
expect(sent, isNotEmpty, reason: 'the bypass swallowed the request');
|
||||
expect(sent, isNotEmpty, reason: 'the provider swallowed the request');
|
||||
expect(only('reached').url.path, contains('/4211/reached'));
|
||||
});
|
||||
});
|
||||
|
||||
319
test/mixed_work_policy_test.dart
Normal file
319
test/mixed_work_policy_test.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/task_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ONE MILER, MIXED WORK — asked of the job, not of the rider
|
||||
///
|
||||
/// The scenario this exists for, stated as the operation states it:
|
||||
///
|
||||
/// A rider in RS Puram is assigned **10 kitchen parcels and 5 CX customer
|
||||
/// pickups**. All fifteen are his, in one shift, on one route.
|
||||
///
|
||||
/// Before this, every operational question was answered by
|
||||
/// `ServiceProfile.active` — a static resolved once from the rider's tenant,
|
||||
/// giving the *same answer for all fifteen rows*. So on a meal tenant the five
|
||||
/// CX pickups were walked straight past the door flow (no parcel count, no
|
||||
/// destination, no weight, no payment) and pivoted on whatever the customer had
|
||||
/// typed into the app days earlier. On a parcel tenant the reverse: ten kitchen
|
||||
/// bags with a known manifest asked the rider to itemise each one.
|
||||
///
|
||||
/// [WorkPolicy] is the bridge. It asks the **row**, and falls back to the line
|
||||
/// only where the row is silent — because most rows still are (see BE-6), and a
|
||||
/// screen that took `unknown` at face value would lose the milk round its
|
||||
/// kitchen headings, which is what happened the two previous times a call site
|
||||
/// was migrated wholesale.
|
||||
///
|
||||
/// These tests pin both halves: the row wins when it speaks, and nothing
|
||||
/// changes when it does not.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
setUp(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
|
||||
|
||||
/// A CX customer pickup, as the queue returns it.
|
||||
Map<String, dynamic> cxPickup() => ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 101,
|
||||
'orderid': 'CX-1',
|
||||
'pickup_source_type': 'customer',
|
||||
'pickup_source_name': 'Anitha R',
|
||||
});
|
||||
|
||||
/// A kitchen / counter collection.
|
||||
Map<String, dynamic> sourcePickup() => ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 201,
|
||||
'orderid': 'KIT-1',
|
||||
'pickup_source_type': 'merchant',
|
||||
'pickup_source_name': 'Sri Balaji Kitchen',
|
||||
});
|
||||
|
||||
/// A row from before these fields existed — the common case today.
|
||||
Map<String, dynamic> silentRow() => ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 301,
|
||||
'orderid': 'OLD-1',
|
||||
});
|
||||
|
||||
group('the row decides, when the row has said something', () {
|
||||
test('a CX customer pickup captures shipment details', () {
|
||||
// The five pickups in the scenario. True on BOTH tenants, which is the
|
||||
// whole change: it is a fact about the job, not about the rider.
|
||||
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
||||
ServiceProfile.setActive(line);
|
||||
expect(
|
||||
WorkPolicy.capturesShipmentDetails(cxPickup()),
|
||||
isTrue,
|
||||
reason: 'a customer door needs the verify flow on ${line.label}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a kitchen collection does not', () {
|
||||
// The ten parcels. Also true on both tenants — the manifest is known
|
||||
// before the rider arrives, whoever he works for.
|
||||
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
||||
ServiceProfile.setActive(line);
|
||||
expect(
|
||||
WorkPolicy.capturesShipmentDetails(sourcePickup()),
|
||||
isFalse,
|
||||
reason: 'a counter manifest is not itemised at the door',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the two coexist in one queue, answering differently', () {
|
||||
// The regression in one line: fifteen rows, two workflows, one rider.
|
||||
final queue = [cxPickup(), sourcePickup()];
|
||||
expect(
|
||||
queue.map(WorkPolicy.capturesShipmentDetails).toList(),
|
||||
[true, false],
|
||||
reason:
|
||||
'this list was [true, true] or [false, false] before — whichever '
|
||||
'the tenant happened to say',
|
||||
);
|
||||
});
|
||||
|
||||
test('a CX pickup never becomes a source/kitchen task', () {
|
||||
final profile = TaskProfile.of(cxPickup());
|
||||
expect(profile.kind, WorkKind.customerPickup);
|
||||
expect(
|
||||
profile.groupsBySource,
|
||||
isFalse,
|
||||
reason: 'a customer door is not a counter and must not be grouped '
|
||||
'under one like a kitchen manifest',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the line still decides where the row is silent', () {
|
||||
// The guard that makes this safe to ship: every existing row and every
|
||||
// existing fixture takes this path, and must behave exactly as before.
|
||||
test('a silent row follows the parcel line', () {
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(
|
||||
WorkPolicy.capturesShipmentDetails(silentRow()),
|
||||
ServiceProfile.parcel.needsVerification,
|
||||
);
|
||||
expect(
|
||||
WorkPolicy.capturesShipmentAddresses(silentRow()),
|
||||
ServiceProfile.parcel.capturesShipmentAddresses,
|
||||
);
|
||||
expect(
|
||||
WorkPolicy.initiatesShipment(silentRow()),
|
||||
ServiceProfile.parcel.initiatesShipment,
|
||||
);
|
||||
});
|
||||
|
||||
test('a silent row follows the meal line', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
expect(
|
||||
WorkPolicy.capturesShipmentDetails(silentRow()),
|
||||
ServiceProfile.milkMan.needsVerification,
|
||||
);
|
||||
expect(
|
||||
WorkPolicy.deliversToCustomer(silentRow()),
|
||||
ServiceProfile.milkMan.deliversToCustomer,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('when an accepted stop leaves Home', () {
|
||||
test('a CX pickup leaves on accept; a kitchen load does not', () {
|
||||
// The per-item replacement for `handsOffAtCollection`. Accepting a
|
||||
// customer visit is an appointment the rider now owes somebody, so it
|
||||
// belongs on Bookings. Accepting a counter load puts nothing in his
|
||||
// hands — he still has to go and collect it.
|
||||
expect(WorkPolicy.staysOnHomeUntilCollected(cxPickup()), isFalse);
|
||||
expect(WorkPolicy.staysOnHomeUntilCollected(sourcePickup()), isTrue);
|
||||
});
|
||||
|
||||
test('and the answer does not change with the tenant', () {
|
||||
// The whole failure this replaces: one answer for every row on the
|
||||
// screen. On a meal tenant an accepted CX pickup sat on Home beside ten
|
||||
// kitchen bags with nothing to tell them apart.
|
||||
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
||||
ServiceProfile.setActive(line);
|
||||
expect(
|
||||
WorkPolicy.staysOnHomeUntilCollected(cxPickup()),
|
||||
isFalse,
|
||||
reason: line.label,
|
||||
);
|
||||
expect(
|
||||
WorkPolicy.staysOnHomeUntilCollected(sourcePickup()),
|
||||
isTrue,
|
||||
reason: line.label,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a silent row still follows the line', () {
|
||||
for (final line in [ServiceProfile.parcel, ServiceProfile.milkMan]) {
|
||||
ServiceProfile.setActive(line);
|
||||
expect(
|
||||
WorkPolicy.staysOnHomeUntilCollected(silentRow()),
|
||||
line.handsOffAtCollection,
|
||||
reason: 'existing payloads must behave exactly as before',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('which surface an item belongs on', () {
|
||||
test('an accepted CX pickup moves Home → Bookings', () {
|
||||
// The rule the scenario names: accepting a customer visit makes it an
|
||||
// appointment the rider owes somebody.
|
||||
final row = cxPickup();
|
||||
expect(WorkPolicy.surfaceOf(row, accepted: false), WorkSurface.home);
|
||||
expect(WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.bookings);
|
||||
});
|
||||
|
||||
test('an accepted kitchen load stays on Home', () {
|
||||
// Accepting a counter load puts nothing in his hands — he still has to go
|
||||
// and collect it.
|
||||
final row = sourcePickup();
|
||||
expect(WorkPolicy.surfaceOf(row, accepted: false), WorkSurface.home);
|
||||
expect(
|
||||
WorkPolicy.surfaceOf(row, accepted: true),
|
||||
WorkSurface.home,
|
||||
reason: 'nothing is in his hands yet',
|
||||
);
|
||||
});
|
||||
|
||||
test('in-custody work is Deliveries, whichever kind it is', () {
|
||||
for (final action in const ['deliver', 'start_delivery', 'inward_at_hub']) {
|
||||
final row = ApiConfig.pickupFromBooking({
|
||||
'bookingid': 401,
|
||||
'next_action': action,
|
||||
});
|
||||
expect(
|
||||
WorkPolicy.surfaceOf(row, accepted: true),
|
||||
WorkSurface.deliveries,
|
||||
reason: action,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('finished work is Activity', () {
|
||||
final row = ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 501,
|
||||
'next_action': 'handed_to_hub',
|
||||
});
|
||||
expect(WorkPolicy.surfaceOf(row, accepted: true), WorkSurface.activity);
|
||||
});
|
||||
});
|
||||
|
||||
group('a base leg is read, never guessed', () {
|
||||
test('only an explicit inward_at_hub ends at a base', () {
|
||||
expect(
|
||||
WorkPolicy.endsAtHub(
|
||||
ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 601,
|
||||
'next_action': 'inward_at_hub',
|
||||
}),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a silent row does NOT fall back to the line here', () {
|
||||
// Deliberately the one method with no fallback. Guessing a base leg from
|
||||
// the rider's tenant is how a hyperlocal parcel gets routed to a counter
|
||||
// and sits there.
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(ServiceProfile.parcel.endsAtHub, isTrue);
|
||||
expect(
|
||||
WorkPolicy.endsAtHub(silentRow()),
|
||||
isFalse,
|
||||
reason: 'the line says the day ends at a base; this ROW does not',
|
||||
);
|
||||
});
|
||||
|
||||
test('a customer delivery does not end at a base', () {
|
||||
expect(
|
||||
WorkPolicy.endsAtHub(
|
||||
ApiConfig.pickupFromBooking(const {
|
||||
'bookingid': 701,
|
||||
'next_action': 'deliver',
|
||||
}),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the RS Puram round', () {
|
||||
test('15 assigned items keep 15 distinct answers, and none vanish', () {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
|
||||
final round = <Map<String, dynamic>>[
|
||||
for (var i = 0; i < 10; i++)
|
||||
ApiConfig.pickupFromBooking({
|
||||
'bookingid': 1000 + i,
|
||||
'orderid': 'KIT-$i',
|
||||
'pickup_source_type': 'merchant',
|
||||
'pickup_source_name': 'Kitchen 2',
|
||||
}),
|
||||
for (var i = 0; i < 5; i++)
|
||||
ApiConfig.pickupFromBooking({
|
||||
'bookingid': 2000 + i,
|
||||
'orderid': 'CX-$i',
|
||||
'pickup_source_type': 'customer',
|
||||
'pickup_source_name': 'Customer $i',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(round, hasLength(15), reason: 'nothing may be filtered away');
|
||||
|
||||
final kitchens = round.where(
|
||||
(r) => TaskProfile.of(r).kind == WorkKind.sourcePickup,
|
||||
);
|
||||
final customers = round.where(
|
||||
(r) => TaskProfile.of(r).kind == WorkKind.customerPickup,
|
||||
);
|
||||
expect(kitchens, hasLength(10));
|
||||
expect(customers, hasLength(5));
|
||||
|
||||
// Accepting all fifteen sends the two kinds to two different surfaces.
|
||||
expect(
|
||||
kitchens.map((r) => WorkPolicy.surfaceOf(r, accepted: true)).toSet(),
|
||||
{WorkSurface.home},
|
||||
);
|
||||
expect(
|
||||
customers.map((r) => WorkPolicy.surfaceOf(r, accepted: true)).toSet(),
|
||||
{WorkSurface.bookings},
|
||||
);
|
||||
|
||||
// And only the five customer doors run the verify flow — on a meal
|
||||
// tenant, where previously none of them would have.
|
||||
expect(
|
||||
customers.every(WorkPolicy.capturesShipmentDetails),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
kitchens.any(WorkPolicy.capturesShipmentDetails),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
193
test/multi_destination_stop_test.dart
Normal file
193
test/multi_destination_stop_test.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
170
test/multidrop_shot_test.dart
Normal file
170
test/multidrop_shot_test.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
@Tags(['shots'])
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'support/golden_fonts.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/mock_backend.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/pickups.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
|
||||
/// A look at the three-door card, rendered from the SAME fixture the running
|
||||
/// app serves — `MockBackend.multiDropVisit` — through the SAME adapter the
|
||||
/// network path uses. Nothing here hand-builds a stop map, so what is on screen
|
||||
/// is what a rider sees when the backend sends a fanned-out customer pickup.
|
||||
///
|
||||
/// Writes PNGs to `test/shots_out/` rather than comparing goldens: the point is
|
||||
/// to look at it, not to pin it.
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
await loadGoldenFonts();
|
||||
Directory('test/shots_out').createSync(recursive: true);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
Get.reset();
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
});
|
||||
|
||||
Future<void> shoot(
|
||||
WidgetTester tester,
|
||||
String name,
|
||||
Widget child, {
|
||||
double height = 340,
|
||||
double width = 390,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({'userid': 1});
|
||||
tester.view.physicalSize = Size(width * 3, height * 3);
|
||||
tester.view.devicePixelRatio = 3.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: Size(width, height),
|
||||
builder: (_, _) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
backgroundColor: ColorConstants.daylightSurface,
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(
|
||||
find.byType(MaterialApp),
|
||||
matchesGoldenFile('shots_out/$name.png'),
|
||||
);
|
||||
}
|
||||
|
||||
/// The fixture the app serves, put through the real adapter.
|
||||
List<Map<String, dynamic>> stops() => [
|
||||
for (final row in MockBackend.multiDropVisit)
|
||||
ApiConfig.pickupFromBooking(Map<String, dynamic>.from(row)),
|
||||
];
|
||||
|
||||
testWidgets('door 1 of 3 — Chennai, two parcels', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final all = stops();
|
||||
await shoot(
|
||||
tester,
|
||||
'multidrop_stop1',
|
||||
PickupCard(
|
||||
item: all[0],
|
||||
displayStep: 1,
|
||||
distanceMeters: 1400,
|
||||
collectedIds: {all[0]['orderid'] as String},
|
||||
outForDeliveryIds: {all[0]['orderid'] as String},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('door 2 of 3 — Ernakulam', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final all = stops();
|
||||
await shoot(
|
||||
tester,
|
||||
'multidrop_stop2',
|
||||
PickupCard(
|
||||
item: all[1],
|
||||
displayStep: 2,
|
||||
distanceMeters: 3100,
|
||||
collectedIds: {all[1]['orderid'] as String},
|
||||
outForDeliveryIds: {all[1]['orderid'] as String},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the three stacked, as the queue shows them', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final all = stops();
|
||||
await shoot(
|
||||
tester,
|
||||
'multidrop_queue',
|
||||
Column(
|
||||
children: [
|
||||
for (var i = 0; i < all.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: PickupCard(
|
||||
item: all[i],
|
||||
displayStep: i + 1,
|
||||
distanceMeters: 1400.0 + i * 900,
|
||||
collectedIds: {all[i]['orderid'] as String},
|
||||
outForDeliveryIds: {all[i]['orderid'] as String},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 960,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a single-destination booking is unchanged', (tester) async {
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
final one = ApiConfig.pickupFromBooking({
|
||||
'bookingid': 900777,
|
||||
'bookingreference': 'MOCK-000777',
|
||||
'status': 'Converted_To_Consignment',
|
||||
'consignmentid': 900777,
|
||||
'consignmentstatus': 'Out_for_Delivery',
|
||||
'next_action': 'deliver',
|
||||
'destinationseq': 0,
|
||||
'destinationcount': 1,
|
||||
'pickup_source_name': 'Ravi Kumar',
|
||||
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
|
||||
'deliveryaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004',
|
||||
'customername': 'Ravi Kumar',
|
||||
'recipientname': 'Joe Mathew',
|
||||
'parcels': [
|
||||
{'bookingparcelid': 1, 'itemcategory': 'General'},
|
||||
],
|
||||
});
|
||||
await shoot(
|
||||
tester,
|
||||
'multidrop_single',
|
||||
PickupCard(
|
||||
item: one,
|
||||
displayStep: 1,
|
||||
distanceMeters: 900,
|
||||
collectedIds: {one['orderid'] as String},
|
||||
outForDeliveryIds: {one['orderid'] as String},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -69,7 +69,7 @@ void main() {
|
||||
expect(tester.getSize(find.byType(OfflineBanner)).height, lessThan(64));
|
||||
});
|
||||
|
||||
testWidgets('it says what happens next, not just what is wrong', (
|
||||
testWidgets('it says what happens next, and promises nothing it cannot do', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, offline: true);
|
||||
@@ -83,7 +83,28 @@ void main() {
|
||||
.first
|
||||
.data!;
|
||||
expect(text, contains('Offline'));
|
||||
expect(text.toLowerCase(), contains('sync'));
|
||||
|
||||
// ── This used to assert the opposite ──
|
||||
//
|
||||
// It required the word "sync", and the banner duly said "Changes will sync
|
||||
// when you're back online". **There is no mutation queue in this app** — no
|
||||
// outbox, no replay, no retry loop. A status the rider set with no signal
|
||||
// was attempted once, failed, and was never sent again. The banner was
|
||||
// promising a mechanism that does not exist, to the one person who would be
|
||||
// blamed when the hub had no record of the stop.
|
||||
//
|
||||
// If a queue is ever built, this assertion is the place to reverse — and
|
||||
// the copy may promise a sync again on the day something performs one.
|
||||
expect(
|
||||
text.toLowerCase(),
|
||||
isNot(contains('sync')),
|
||||
reason: 'nothing in this app replays a failed mutation',
|
||||
);
|
||||
expect(
|
||||
text.toLowerCase(),
|
||||
contains('connection'),
|
||||
reason: 'it must still say what he needs, not only that he is offline',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('online, it occupies no space at all', (tester) async {
|
||||
|
||||
213
test/parcel_photos_test.dart
Normal file
213
test/parcel_photos_test.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/miler_api.dart';
|
||||
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE DOOR PHOTOGRAPH REACHES THE HUB
|
||||
///
|
||||
/// `POST /miler/bookings/:id/parcel` has accepted `photos` for as long as the
|
||||
/// route has existed. The server's own note says why it is there: *"Weight
|
||||
/// without a photograph is a number the customer has no way to check, and this
|
||||
/// is the only point in the flow where anyone is standing next to the parcel."*
|
||||
///
|
||||
/// The app never sent it — and not by oversight in one place, but through three
|
||||
/// separate defects that each had to be fixed:
|
||||
///
|
||||
/// 1. `ParcelEntry.toJson()` had no `photos` key at all.
|
||||
/// 2. `uploadProof` kept the public **URL** from the sign response and threw
|
||||
/// away the **key**, which is what `photos` is specified in.
|
||||
/// 3. The pickup upload passed a **booking** id in the `consignmentid` slot,
|
||||
/// so every pickup proof this app ever uploaded was filed under a
|
||||
/// consignment number that did not exist.
|
||||
///
|
||||
/// Meanwhile `StopVerificationPage` *compels* the photograph — the rider cannot
|
||||
/// confirm the stop without one. So the app demanded the single piece of
|
||||
/// evidence that settles a dispute, uploaded it, and dropped the reference.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('ParcelEntry serialisation', () {
|
||||
test('photos are sent when there are any', () {
|
||||
final json = const ParcelEntry(
|
||||
weight: 2.5,
|
||||
photos: ['pickup_proof/pickup_proof-4821-20260916-101402-a1b2.jpg'],
|
||||
).toJson();
|
||||
|
||||
expect(json['photos'], isA<List>());
|
||||
expect(
|
||||
json['photos'],
|
||||
['pickup_proof/pickup_proof-4821-20260916-101402-a1b2.jpg'],
|
||||
);
|
||||
expect(json['weight'], 2.5);
|
||||
});
|
||||
|
||||
test('the key is a storage key, never a URL', () {
|
||||
// The customer is served a short-lived signed link *derived from* the
|
||||
// key. A URL stored here either expires or, worse, never does.
|
||||
final json = const ParcelEntry(
|
||||
weight: 1,
|
||||
photos: ['pickup_proof/pickup_proof-4821-20260916-101402-a1b2.jpg'],
|
||||
).toJson();
|
||||
final photo = (json['photos'] as List).single.toString();
|
||||
|
||||
expect(photo, isNot(startsWith('http')));
|
||||
expect(photo, isNot(contains('://')));
|
||||
expect(photo, contains('/'), reason: 'a key is {folder}/{file}');
|
||||
});
|
||||
|
||||
test('no photo means the field is OMITTED, not sent empty', () {
|
||||
// An empty array is a claim that no photograph was taken. A failed
|
||||
// upload is not that claim, and the difference matters to whoever reads
|
||||
// the record afterwards.
|
||||
final json = const ParcelEntry(weight: 1).toJson();
|
||||
expect(json.containsKey('photos'), isFalse);
|
||||
});
|
||||
|
||||
test('a local filesystem path is never what gets sent', () {
|
||||
// The shape the bug produced: `parcelImage` held `/data/user/0/…/x.jpg`
|
||||
// and nothing ever turned it into a reference the server could resolve.
|
||||
final json = const ParcelEntry(weight: 1).toJson();
|
||||
expect(json.toString(), isNot(contains('/data/user/')));
|
||||
expect(json.toString(), isNot(contains('.jpg')));
|
||||
});
|
||||
});
|
||||
|
||||
group('the parcel request that actually leaves the phone', () {
|
||||
late List<http.Request> sent;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'});
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
return http.Response('{"success":true,"data":{}}', 200);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
Map<String, dynamic> bodyOf(http.Request r) =>
|
||||
jsonDecode(r.body) as Map<String, dynamic>;
|
||||
|
||||
test('photos ride on the wire to /parcel', () async {
|
||||
await MilerApi.submitParcels(4821, const [
|
||||
ParcelEntry(weight: 2, photos: ['pickup_proof/p-4821-x.jpg']),
|
||||
ParcelEntry(weight: 2),
|
||||
]);
|
||||
|
||||
expect(sent, hasLength(1));
|
||||
expect(sent.single.url.path, endsWith('/miler/bookings/4821/parcel'));
|
||||
|
||||
final parcels = bodyOf(sent.single)['parcels'] as List;
|
||||
expect(parcels, hasLength(2));
|
||||
expect((parcels[0] as Map)['photos'], ['pickup_proof/p-4821-x.jpg']);
|
||||
expect(
|
||||
(parcels[1] as Map).containsKey('photos'),
|
||||
isFalse,
|
||||
reason: 'one photograph of the load, not one claimed per box',
|
||||
);
|
||||
});
|
||||
|
||||
test('the sign call keys a PRE-pickup proof on the BOOKING', () async {
|
||||
// There is no consignment yet — `pickup-complete` has not run. The server
|
||||
// builds the storage key from consignmentid, then bookingid, then the
|
||||
// rider; passing a booking id in the consignment slot filed the proof
|
||||
// under a consignment number that did not exist.
|
||||
await MilerApi.signUpload(
|
||||
purpose: MilerApi.proofPickup,
|
||||
bookingId: 4821,
|
||||
);
|
||||
|
||||
final body = bodyOf(sent.single);
|
||||
expect(body['bookingid'], 4821);
|
||||
expect(
|
||||
body.containsKey('consignmentid'),
|
||||
isFalse,
|
||||
reason: 'a booking id must never be sent as a consignment id',
|
||||
);
|
||||
expect(body['purpose'], 'pickup_proof');
|
||||
});
|
||||
|
||||
test('a delivery proof still keys on the consignment', () async {
|
||||
// The other half of the contract, unchanged — `deliver` takes a URL and
|
||||
// its proof belongs to a consignment that exists by then.
|
||||
await MilerApi.signUpload(
|
||||
purpose: MilerApi.proofDelivery,
|
||||
consignmentId: 77,
|
||||
);
|
||||
|
||||
final body = bodyOf(sent.single);
|
||||
expect(body['consignmentid'], 77);
|
||||
expect(body.containsKey('bookingid'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('the provider turns a verification map into parcels', () {
|
||||
late List<http.Request> sent;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({'userid': 38, 'authtoken': 't'});
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
return http.Response('{"success":true,"data":{}}', 200);
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
test('a key on the verification map reaches the first parcel', () async {
|
||||
await UpdatePickupProvider().submitParcels(4821, const {
|
||||
'pickup': {
|
||||
'collected': 3,
|
||||
'weight': '6',
|
||||
'photos': ['pickup_proof/p-4821-x.jpg'],
|
||||
},
|
||||
});
|
||||
|
||||
final parcels =
|
||||
(jsonDecode(sent.single.body) as Map)['parcels'] as List;
|
||||
expect(parcels, hasLength(3), reason: 'three boxes were collected');
|
||||
expect((parcels[0] as Map)['photos'], ['pickup_proof/p-4821-x.jpg']);
|
||||
expect((parcels[1] as Map).containsKey('photos'), isFalse);
|
||||
expect((parcels[2] as Map).containsKey('photos'), isFalse);
|
||||
|
||||
// The weight is still split evenly across the three — unchanged.
|
||||
expect((parcels[0] as Map)['weight'], 2.0);
|
||||
});
|
||||
|
||||
test('a failed upload still records the parcels', () async {
|
||||
// A dead object store must not strand a rider at a door holding parcels
|
||||
// he cannot record. The pickup goes through; the gap is logged.
|
||||
await UpdatePickupProvider().submitParcels(4821, const {
|
||||
'pickup': {'collected': 1, 'weight': '2'},
|
||||
});
|
||||
|
||||
expect(sent, hasLength(1), reason: 'the parcels were still submitted');
|
||||
final parcels =
|
||||
(jsonDecode(sent.single.body) as Map)['parcels'] as List;
|
||||
expect((parcels[0] as Map).containsKey('photos'), isFalse);
|
||||
});
|
||||
|
||||
test('a blank key is dropped rather than sent', () async {
|
||||
await UpdatePickupProvider().submitParcels(4821, const {
|
||||
'pickup': {
|
||||
'collected': 1,
|
||||
'weight': '2',
|
||||
'photos': ['', ' '],
|
||||
},
|
||||
});
|
||||
|
||||
final parcels =
|
||||
(jsonDecode(sent.single.body) as Map)['parcels'] as List;
|
||||
expect((parcels[0] as Map).containsKey('photos'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
386
test/set_pin_flow_test.dart
Normal file
386
test/set_pin_flow_test.dart
Normal file
@@ -0,0 +1,386 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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/miler_api.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// SELF-SET PIN ON FIRST SIGN-IN
|
||||
///
|
||||
/// The console no longer issues a rider a PIN. `POST /miler/login` answers
|
||||
/// `pin_set`, and that one boolean decides which screen he is owed:
|
||||
///
|
||||
/// pin_set: false → Set-PIN → POST /miler/set-pin → logged in
|
||||
/// pin_set: true → Enter-PIN → POST /miler/verify-pin → logged in
|
||||
///
|
||||
/// ── What this replaces, and why it was a dead end ──
|
||||
///
|
||||
/// The app read only the **status code** of `/miler/login` and threw the body
|
||||
/// away. From "an account exists" it inferred Enter-PIN; from "it does not" it
|
||||
/// inferred an OTP branch that verified nothing — `verifyOtp` returned `true`
|
||||
/// without checking a digit — and ended at a Create-MPIN screen whose save
|
||||
/// button called `updatePin`, which had no route behind it and returned a
|
||||
/// manufactured `403 "Your MPIN is issued by your office and cannot be changed
|
||||
/// from the app."`
|
||||
///
|
||||
/// So a rider with no PIN could not sign in by any path, and the three seeded
|
||||
/// test riders (`8000000001`–`3`) were unreachable.
|
||||
///
|
||||
/// ── The rule these tests exist to hold ──
|
||||
///
|
||||
/// **Branch on the boolean, never on the message.** `"PIN verification
|
||||
/// required"` is prose and prose gets reworded; the day it does, a
|
||||
/// string-matching client sends every rider to the wrong screen.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late List<http.Request> sent;
|
||||
|
||||
/// Installs a fake server. [handler] answers by path.
|
||||
void serve(http.Response Function(http.Request req) handler) {
|
||||
sent = <http.Request>[];
|
||||
MilerApi.client = MockClient((req) async {
|
||||
sent.add(req);
|
||||
return handler(req);
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||||
tearDown(() => MilerApi.client = http.Client());
|
||||
|
||||
Map<String, dynamic> bodyOf(http.Request r) =>
|
||||
jsonDecode(r.body) as Map<String, dynamic>;
|
||||
|
||||
/// The session envelope `set-pin` and `verify-pin` both answer with.
|
||||
String sessionBody({int userId = 46}) => jsonEncode({
|
||||
'success': true,
|
||||
'token': 'jwt-for-$userId',
|
||||
'tenantid': 1,
|
||||
'tenantname': 'Doormile Coimbatore Logistics',
|
||||
'user': {
|
||||
'userid': userId,
|
||||
'authname': 'Seed Rider',
|
||||
'contactno': '8000000001',
|
||||
'profile': {
|
||||
'userid': userId,
|
||||
'displayname': 'Seed Rider',
|
||||
'phone': '8000000001',
|
||||
'availabilitystatus': 'Offline',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
group('pin_set decides the screen', () {
|
||||
test('pin_set:false → the rider must set one', () async {
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({
|
||||
'success': true,
|
||||
'message': 'PIN verification required',
|
||||
'phone': '8000000001',
|
||||
'pin_set': false,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final res = await MilerApi.login('8000000001');
|
||||
expect(res.ok, isTrue);
|
||||
expect(MilerApi.pinSetOf(res), isFalse);
|
||||
});
|
||||
|
||||
test('pin_set:true → the existing Enter-PIN path', () async {
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({
|
||||
'success': true,
|
||||
'message': 'PIN verification required',
|
||||
'phone': '9000000009',
|
||||
'pin_set': true,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
|
||||
final res = await MilerApi.login('9000000009');
|
||||
expect(MilerApi.pinSetOf(res), isTrue);
|
||||
});
|
||||
|
||||
test('the MESSAGE is identical in both cases — only the flag differs', () {
|
||||
// The reason this is a boolean and not a string match. Both responses
|
||||
// carry "PIN verification required"; a client reading the sentence cannot
|
||||
// tell a first-time rider from a returning one.
|
||||
const first = '{"success":true,"message":"PIN verification required",'
|
||||
'"pin_set":false}';
|
||||
const returning = '{"success":true,"message":"PIN verification required",'
|
||||
'"pin_set":true}';
|
||||
expect(
|
||||
jsonDecode(first)['message'],
|
||||
jsonDecode(returning)['message'],
|
||||
);
|
||||
expect(
|
||||
jsonDecode(first)['pin_set'],
|
||||
isNot(jsonDecode(returning)['pin_set']),
|
||||
);
|
||||
});
|
||||
|
||||
test('an absent pin_set is null, and callers fall back to Enter-PIN', () async {
|
||||
// An older server, or a body that did not carry the field. Enter-PIN is
|
||||
// the safe direction: a rider who HAS a PIN can sign in, and one who does
|
||||
// not gets a refusal he can report — rather than a Set-PIN screen that
|
||||
// will 409 and strand him.
|
||||
serve((_) => http.Response(jsonEncode({'success': true}), 200));
|
||||
final res = await MilerApi.login('8000000001');
|
||||
expect(MilerApi.pinSetOf(res), isNull);
|
||||
});
|
||||
|
||||
test('a string "false" is read as false, not as truthy prose', () async {
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({'success': true, 'pin_set': 'false'}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
expect(MilerApi.pinSetOf(await MilerApi.login('8000000001')), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('POST /miler/set-pin', () {
|
||||
test('sends the contract body and stores the session', () async {
|
||||
serve((req) => http.Response(sessionBody(), 200));
|
||||
|
||||
final res = await MilerApi.setPin(
|
||||
phone: '8000000001',
|
||||
pin: '4271',
|
||||
deviceToken: 'fcm-token-abc',
|
||||
);
|
||||
|
||||
expect(res.ok, isTrue);
|
||||
expect(sent.single.url.path, endsWith('/miler/set-pin'));
|
||||
|
||||
final body = bodyOf(sent.single);
|
||||
expect(body['phone'], '8000000001');
|
||||
expect(body['new_pin'], '4271');
|
||||
expect(body['configid'], MilerApi.configId);
|
||||
expect(body['device_token'], 'fcm-token-abc');
|
||||
expect(
|
||||
body.containsKey('pin'),
|
||||
isFalse,
|
||||
reason: 'the field is new_pin on this route, not pin',
|
||||
);
|
||||
|
||||
// The whole point: the rider is signed in by this one call.
|
||||
expect(await ApiConfig.getToken(), 'jwt-for-46');
|
||||
});
|
||||
|
||||
test('the PIN goes as a STRING, so a leading zero survives', () async {
|
||||
// The backend bcrypt-compares the PIN as text. Round-tripping "0512"
|
||||
// through an int gives 512, and a valid PIN is refused forever.
|
||||
serve((req) => http.Response(sessionBody(), 200));
|
||||
await MilerApi.setPin(phone: '8000000001', pin: '0512');
|
||||
expect(bodyOf(sent.single)['new_pin'], '0512');
|
||||
});
|
||||
|
||||
test('device_token is omitted rather than sent empty', () async {
|
||||
serve((req) => http.Response(sessionBody(), 200));
|
||||
await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||||
expect(bodyOf(sent.single).containsKey('device_token'), isFalse);
|
||||
});
|
||||
|
||||
test('409 — a PIN already exists, and no session is minted', () async {
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({
|
||||
'success': false,
|
||||
'message': 'PIN already set for this account',
|
||||
}),
|
||||
409,
|
||||
),
|
||||
);
|
||||
|
||||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||||
expect(res.ok, isFalse);
|
||||
expect(res.status, 409);
|
||||
expect(
|
||||
await ApiConfig.getToken(),
|
||||
isNull,
|
||||
reason: 'a refused set-pin must never leave a token behind',
|
||||
);
|
||||
});
|
||||
|
||||
test('404 — the number is not registered', () async {
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({'success': false, 'message': 'no miler account'}),
|
||||
404,
|
||||
),
|
||||
);
|
||||
final res = await MilerApi.setPin(phone: '7000000000', pin: '4271');
|
||||
expect(res.status, 404);
|
||||
expect(res.ok, isFalse);
|
||||
});
|
||||
|
||||
test('403 — the account is not an active miler', () async {
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({'success': false, 'message': 'inactive'}),
|
||||
403,
|
||||
),
|
||||
);
|
||||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||||
expect(res.status, 403);
|
||||
expect(res.ok, isFalse);
|
||||
});
|
||||
|
||||
test('a 200 with success:false is still a failure', () async {
|
||||
// The envelope rule: the status line alone is not the answer.
|
||||
serve(
|
||||
(_) => http.Response(
|
||||
jsonEncode({'success': false, 'message': 'nope'}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||||
expect(res.ok, isFalse);
|
||||
expect(await ApiConfig.getToken(), isNull);
|
||||
});
|
||||
|
||||
test('a success carrying no token leaves no session', () async {
|
||||
// The credentials were accepted and there is nothing to sign in with.
|
||||
// An integration fault — and it must not read as a stored session.
|
||||
serve(
|
||||
(_) => http.Response(jsonEncode({'success': true}), 200),
|
||||
);
|
||||
final res = await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||||
expect(res.ok, isTrue);
|
||||
expect(await ApiConfig.getToken(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('the returning rider is untouched', () {
|
||||
test('verify-pin still sends `pin` to its own route', () async {
|
||||
serve((req) => http.Response(sessionBody(userId: 38), 200));
|
||||
|
||||
await MilerApi.verifyPin(phone: '9000000009', pin: '1234');
|
||||
|
||||
expect(sent.single.url.path, endsWith('/miler/verify-pin'));
|
||||
final body = bodyOf(sent.single);
|
||||
expect(body['pin'], '1234');
|
||||
expect(
|
||||
body.containsKey('new_pin'),
|
||||
isFalse,
|
||||
reason: 'new_pin belongs to set-pin alone',
|
||||
);
|
||||
expect(await ApiConfig.getToken(), 'jwt-for-38');
|
||||
});
|
||||
|
||||
test('the two routes are genuinely different endpoints', () async {
|
||||
serve((req) => http.Response(sessionBody(), 200));
|
||||
await MilerApi.setPin(phone: '8000000001', pin: '4271');
|
||||
await MilerApi.verifyPin(phone: '8000000001', pin: '4271');
|
||||
|
||||
expect(sent, hasLength(2));
|
||||
expect(sent[0].url.path, endsWith('/miler/set-pin'));
|
||||
expect(sent[1].url.path, endsWith('/miler/verify-pin'));
|
||||
});
|
||||
});
|
||||
|
||||
group('the seeded riders', () {
|
||||
// The three created with no PIN, for exactly this flow. Before this change
|
||||
// none of them could sign in: precheck read "account exists" and sent them
|
||||
// to Enter-PIN, where no PIN they typed would ever match.
|
||||
for (final phone in const ['8000000001', '8000000002', '8000000003']) {
|
||||
test('$phone is routed to Set-PIN and signs in', () async {
|
||||
serve((req) {
|
||||
if (req.url.path.endsWith('/miler/login')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'success': true,
|
||||
'message': 'PIN verification required',
|
||||
'phone': phone,
|
||||
'pin_set': false,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(sessionBody(), 200);
|
||||
});
|
||||
|
||||
final login = await MilerApi.login(phone);
|
||||
expect(
|
||||
MilerApi.pinSetOf(login),
|
||||
isFalse,
|
||||
reason: 'seeded riders are created without a PIN',
|
||||
);
|
||||
|
||||
final set = await MilerApi.setPin(phone: phone, pin: '4271');
|
||||
expect(set.ok, isTrue);
|
||||
expect(await ApiConfig.getToken(), isNotNull);
|
||||
|
||||
expect(sent, hasLength(2));
|
||||
expect(sent[0].url.path, endsWith('/miler/login'));
|
||||
expect(sent[1].url.path, endsWith('/miler/set-pin'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('no master PIN and no dead OTP path survive', () {
|
||||
/// Source with every comment line stripped.
|
||||
///
|
||||
/// These assertions are about **code**, not prose — and the distinction is
|
||||
/// not academic: the comments that explain why the master-PIN constants
|
||||
/// were removed necessarily quote their names, and a naive scan matches its
|
||||
/// own explanation and fails. Strip the commentary, assert the code.
|
||||
String codeOf(String path) => File(path)
|
||||
.readAsLinesSync()
|
||||
.where((l) => !l.trimLeft().startsWith('//'))
|
||||
.join('\n');
|
||||
|
||||
final auth = codeOf('lib/controllers/auth.dart');
|
||||
final signIn = codeOf('lib/views/onboardscreens/Sign_in.dart');
|
||||
|
||||
test('the 1234 master-PIN constants are gone', () {
|
||||
// `_masterPinValue = '1234'`, `masterPinValue` and
|
||||
// `forceMasterPinPrefKey` were declared on AuthController and read by
|
||||
// nothing — leftovers of a removed feature. A public constant named
|
||||
// `masterPinValue` holding four digits reads as a back door whether or
|
||||
// not anything calls it, and it collided with a PIN a rider could now
|
||||
// legitimately choose.
|
||||
expect(auth, isNot(contains('masterPinValue')));
|
||||
expect(auth, isNot(contains('forceMasterPinPrefKey')));
|
||||
expect(auth, isNot(contains('_forceMasterPinFlow')));
|
||||
});
|
||||
|
||||
test('the office-issues-your-PIN dead end is gone', () {
|
||||
// The sentence the Create-MPIN screen used to end at, because there was
|
||||
// no route behind its save button. There is one now.
|
||||
expect(
|
||||
auth,
|
||||
isNot(contains('cannot be changed from the app')),
|
||||
reason: 'riders set their own PIN on first sign-in',
|
||||
);
|
||||
});
|
||||
|
||||
test('sign-in no longer routes anyone to the unverified OTP screen', () {
|
||||
// `verifyOtp` returned true without checking a digit, and the screen it
|
||||
// led to could not write a PIN. Nothing may route there.
|
||||
expect(signIn, isNot(contains('OtpPage')));
|
||||
expect(signIn, contains('AuthNext.setPin'));
|
||||
expect(signIn, contains('AuthNext.verifyPin'));
|
||||
});
|
||||
|
||||
test('sign-in branches on the enum, never on response prose', () {
|
||||
expect(
|
||||
signIn,
|
||||
isNot(contains('PIN verification required')),
|
||||
reason: 'the message is prose and prose gets reworded',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
BIN
test/shots_out/multidrop_queue.png
Normal file
BIN
test/shots_out/multidrop_queue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
BIN
test/shots_out/multidrop_single.png
Normal file
BIN
test/shots_out/multidrop_single.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
test/shots_out/multidrop_stop1.png
Normal file
BIN
test/shots_out/multidrop_stop1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
test/shots_out/multidrop_stop2.png
Normal file
BIN
test/shots_out/multidrop_stop2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
253
test/task_profile_test.dart
Normal file
253
test/task_profile_test.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:miler/data/task_profile.dart';
|
||||
|
||||
/// One rider, mixed work, and every row deciding its own workflow.
|
||||
///
|
||||
/// These tests exist because the app used to answer "what kind of work is
|
||||
/// this?" by looking at the **rider** — his tenant, resolved once at login into
|
||||
/// a single app-wide service line. That answer is the same for every row on the
|
||||
/// screen, so a customer pickup sitting beside a kitchen collection either took
|
||||
/// the kitchen's rules or disappeared. The rider is one person with a mixed
|
||||
/// queue; the queue decides.
|
||||
///
|
||||
/// Every input below is what `GET /miler/bookings` actually sends.
|
||||
|
||||
Map<String, dynamic> _row({
|
||||
String? nextAction,
|
||||
String? pickupSourceType,
|
||||
String? stopType,
|
||||
}) => <String, dynamic>{
|
||||
if (nextAction != null) 'next_action': nextAction,
|
||||
if (pickupSourceType != null) 'pickup_source_type': pickupSourceType,
|
||||
if (stopType != null) 'stoptype': stopType,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('a row is classified from the server\'s own words', () {
|
||||
test('a customer door pickup is a customer pickup', () {
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'customer'),
|
||||
);
|
||||
expect(t.kind, WorkKind.customerPickup);
|
||||
expect(t.capturesShipmentDetails, isTrue);
|
||||
expect(t.groupsBySource, isFalse);
|
||||
});
|
||||
|
||||
test('a kitchen load is a source pickup, and groups', () {
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'kitchen'),
|
||||
);
|
||||
expect(t.kind, WorkKind.sourcePickup);
|
||||
expect(t.groupsBySource, isTrue);
|
||||
expect(t.capturesShipmentDetails, isFalse);
|
||||
});
|
||||
|
||||
test('a base collection is a source pickup too', () {
|
||||
expect(
|
||||
TaskProfile.of(_row(nextAction: 'pickup', pickupSourceType: 'hub')).kind,
|
||||
WorkKind.sourcePickup,
|
||||
);
|
||||
});
|
||||
|
||||
test('carrying it to a receiver is a delivery', () {
|
||||
expect(
|
||||
TaskProfile.of(_row(nextAction: 'deliver')).kind,
|
||||
WorkKind.customerDelivery,
|
||||
);
|
||||
expect(
|
||||
TaskProfile.of(_row(nextAction: 'start_delivery')).kind,
|
||||
WorkKind.customerDelivery,
|
||||
);
|
||||
});
|
||||
|
||||
test('carrying it to a base is a handover', () {
|
||||
final t = TaskProfile.of(_row(nextAction: 'inward_at_hub'));
|
||||
expect(t.kind, WorkKind.hubHandover);
|
||||
expect(t.endsAtHub, isTrue);
|
||||
expect(t.deliversToCustomer, isFalse);
|
||||
});
|
||||
|
||||
test('terminal rows are done', () {
|
||||
expect(TaskProfile.of(_row(nextAction: 'none')).kind, WorkKind.done);
|
||||
expect(
|
||||
TaskProfile.of(_row(nextAction: 'handed_to_hub')).kind,
|
||||
WorkKind.done,
|
||||
);
|
||||
});
|
||||
|
||||
test('collected with no stated leg is custody-without-destination', () {
|
||||
// NextLeg.unknown's case: it is in his bag and nobody has said where it
|
||||
// goes. Not a delivery (no destination to offer) and not done (he is
|
||||
// still holding it).
|
||||
final t = TaskProfile.of(_row(stopType: 'delivery'));
|
||||
expect(t.kind, WorkKind.unknown);
|
||||
expect(t.deliversToCustomer, isFalse);
|
||||
expect(t.endsAtHub, isFalse);
|
||||
});
|
||||
|
||||
test('an unheard-of source takes the safe shape, never a guess', () {
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'something_new'),
|
||||
);
|
||||
expect(t.kind, WorkKind.unknown);
|
||||
expect(
|
||||
t.capturesShipmentDetails,
|
||||
isFalse,
|
||||
reason: 'the door-capture flow is a claim this row has not earned',
|
||||
);
|
||||
expect(
|
||||
t.groupsBySource,
|
||||
isFalse,
|
||||
reason: 'nor is a heading it can be filed under',
|
||||
);
|
||||
});
|
||||
|
||||
test('a row with no type fields at all does not group', () {
|
||||
// Every fixture written before pickup_source_type shipped, and every
|
||||
// booking still in flight from then. This defaulted to sourcePickup once
|
||||
// and split a one-base logistics day into a group per customer --
|
||||
// hub_pickup_group_test.dart is the test that caught it.
|
||||
final legacy = <String, dynamic>{
|
||||
'orderid': '9',
|
||||
'pickupid': '9',
|
||||
'pickupaddress': 'RS Puram, Coimbatore',
|
||||
'type': 'pickup',
|
||||
};
|
||||
final t = TaskProfile.of(legacy);
|
||||
expect(t.kind, WorkKind.unknown);
|
||||
expect(t.groupsBySource, isFalse);
|
||||
expect(t.capturesShipmentDetails, isFalse);
|
||||
});
|
||||
|
||||
test('the leg outranks where it was collected from', () {
|
||||
// Picked up at a customer's door, now in the bag on its way to a base.
|
||||
// It is a handover, not a pickup — it stopped being a pickup the moment
|
||||
// it was collected, and only the leg knows that.
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'inward_at_hub', pickupSourceType: 'customer'),
|
||||
);
|
||||
expect(t.kind, WorkKind.hubHandover);
|
||||
});
|
||||
});
|
||||
|
||||
group('surfaces', () {
|
||||
test('an unaccepted customer pickup waits on Home', () {
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'customer'),
|
||||
);
|
||||
expect(t.surfaceWhen(accepted: false), WorkSurface.home);
|
||||
});
|
||||
|
||||
test('accepting a customer pickup moves it to Bookings', () {
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'customer'),
|
||||
);
|
||||
expect(t.surfaceWhen(accepted: true), WorkSurface.bookings);
|
||||
});
|
||||
|
||||
test('a kitchen load stays on Home even once accepted', () {
|
||||
// Accepting a manifest puts nothing in his hands. It leaves Home when he
|
||||
// is actually carrying it, not when he agrees to fetch it.
|
||||
final t = TaskProfile.of(
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'kitchen'),
|
||||
);
|
||||
expect(t.surfaceWhen(accepted: true), WorkSurface.home);
|
||||
});
|
||||
|
||||
test('anything in custody is on Deliveries', () {
|
||||
for (final action in ['deliver', 'start_delivery', 'inward_at_hub']) {
|
||||
expect(
|
||||
TaskProfile.of(_row(nextAction: action)).surfaceWhen(accepted: true),
|
||||
WorkSurface.deliveries,
|
||||
reason: action,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
TaskProfile.of(_row(stopType: 'delivery')).surfaceWhen(accepted: true),
|
||||
WorkSurface.deliveries,
|
||||
);
|
||||
});
|
||||
|
||||
test('finished work is on Activity', () {
|
||||
expect(
|
||||
TaskProfile.of(_row(nextAction: 'none')).surfaceWhen(accepted: true),
|
||||
WorkSurface.activity,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('the mixed shift', () {
|
||||
// The case the architecture exists for: one rider around RS Puram holding
|
||||
// ten meal parcels to collect at a Gandhipuram kitchen and five customer
|
||||
// pickups at five different doors, all at once.
|
||||
final queue = <Map<String, dynamic>>[
|
||||
for (var i = 0; i < 10; i++)
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'kitchen'),
|
||||
for (var i = 0; i < 5; i++)
|
||||
_row(nextAction: 'pickup', pickupSourceType: 'customer'),
|
||||
];
|
||||
|
||||
test('all fifteen survive classification', () {
|
||||
final kinds = queue.map((r) => TaskProfile.of(r).kind).toList();
|
||||
expect(kinds.length, 15);
|
||||
expect(kinds.where((k) => k == WorkKind.sourcePickup).length, 10);
|
||||
expect(kinds.where((k) => k == WorkKind.customerPickup).length, 5);
|
||||
expect(
|
||||
kinds.contains(WorkKind.unknown),
|
||||
isFalse,
|
||||
reason: 'nothing falls through to the safe shape when the server spoke',
|
||||
);
|
||||
});
|
||||
|
||||
test('accepting the five does not move the ten', () {
|
||||
final surfaces = queue
|
||||
.map((r) => TaskProfile.of(r).surfaceWhen(accepted: true))
|
||||
.toList();
|
||||
expect(surfaces.where((s) => s == WorkSurface.home).length, 10);
|
||||
expect(surfaces.where((s) => s == WorkSurface.bookings).length, 5);
|
||||
});
|
||||
|
||||
test('collected meal parcels coexist with a CX handover on Deliveries', () {
|
||||
final afterCollection = <Map<String, dynamic>>[
|
||||
for (var i = 0; i < 10; i++) _row(nextAction: 'deliver'),
|
||||
// The CX pickup, now completed, has become a base handover.
|
||||
_row(nextAction: 'inward_at_hub', pickupSourceType: 'customer'),
|
||||
];
|
||||
final surfaces = afterCollection
|
||||
.map((r) => TaskProfile.of(r).surfaceWhen(accepted: true))
|
||||
.toSet();
|
||||
expect(surfaces, {WorkSurface.deliveries});
|
||||
});
|
||||
});
|
||||
|
||||
group('a trip is described by what is actually in it', () {
|
||||
Map<String, dynamic> row({String? a, String? src}) => <String, dynamic>{
|
||||
if (a != null) 'next_action': a,
|
||||
if (src != null) 'pickup_source_type': src,
|
||||
};
|
||||
|
||||
test('a mixed trip both groups and ends at a base', () {
|
||||
final stops = [
|
||||
row(a: 'pickup', src: 'kitchen'),
|
||||
row(a: 'pickup', src: 'customer'),
|
||||
row(a: 'inward_at_hub'),
|
||||
];
|
||||
expect(TripShape.groupsBySource(stops), isTrue);
|
||||
expect(TripShape.endsAtHub(stops), isTrue);
|
||||
expect(TripShape.hasCustomerPickup(stops), isTrue);
|
||||
});
|
||||
|
||||
test('one parcel bound for a base ends the whole day at that base', () {
|
||||
// `any`, not `every`. Telling a rider "END · HOME" while he is still
|
||||
// carrying something the network needs is the failure worth avoiding.
|
||||
final stops = [row(a: 'deliver'), row(a: 'inward_at_hub')];
|
||||
expect(TripShape.endsAtHub(stops), isTrue);
|
||||
});
|
||||
|
||||
test('a pure meal round neither groups to a base nor returns to one', () {
|
||||
final stops = [row(a: 'deliver'), row(a: 'deliver')];
|
||||
expect(TripShape.endsAtHub(stops), isFalse);
|
||||
expect(TripShape.hasCustomerPickup(stops), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/work_scope.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// TWO LINES, ONE HANDSET, NO MIXING
|
||||
/// ONE RIDER, ONE DRAWER
|
||||
///
|
||||
/// Miler runs a milk-man round and a logistics day off one app and one login,
|
||||
/// and it kept finished work under *global* SharedPreferences keys —
|
||||
@@ -18,75 +18,81 @@ import 'package:miler/data/work_scope.dart';
|
||||
///
|
||||
/// These pin the ownership rule at the data boundary. Nothing here filters on
|
||||
/// a display string — no kitchen names, no "Milk", no screen titles — only on
|
||||
/// identity the session can prove: rider, tenant, line.
|
||||
/// identity the session can prove: rider and tenant.
|
||||
///
|
||||
/// ── The line came OUT of the key, deliberately ──
|
||||
///
|
||||
/// It used to be a third part: `completed_bookings::u38.t13.milkMan`. That was
|
||||
/// right while a rider was one kind of rider 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 line split one day
|
||||
/// across two drawers — so work he finished an hour ago vanished when the app
|
||||
/// resolved him differently. Two tests below used to assert that split and now
|
||||
/// assert the opposite; they are the record of the rule changing.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
const milkA = WorkScope(userId: 38, tenantId: 13, line: ServiceLine.milkMan);
|
||||
const logisticsA = WorkScope(
|
||||
userId: 38,
|
||||
tenantId: 13,
|
||||
line: ServiceLine.parcel,
|
||||
);
|
||||
const milkB = WorkScope(userId: 99, tenantId: 13, line: ServiceLine.milkMan);
|
||||
const otherTenant = WorkScope(
|
||||
userId: 38,
|
||||
tenantId: 77,
|
||||
line: ServiceLine.milkMan,
|
||||
);
|
||||
const riderA = WorkScope(userId: 38, tenantId: 13);
|
||||
const riderB = WorkScope(userId: 99, tenantId: 13);
|
||||
const otherTenant = WorkScope(userId: 38, tenantId: 77);
|
||||
|
||||
group('the key', () {
|
||||
test('rider, tenant and line each change the drawer', () {
|
||||
test('rider and tenant each change the drawer', () {
|
||||
final keys = {
|
||||
milkA.scoped('completed_bookings'),
|
||||
logisticsA.scoped('completed_bookings'),
|
||||
milkB.scoped('completed_bookings'),
|
||||
riderA.scoped('completed_bookings'),
|
||||
riderB.scoped('completed_bookings'),
|
||||
otherTenant.scoped('completed_bookings'),
|
||||
};
|
||||
expect(
|
||||
keys.length,
|
||||
4,
|
||||
3,
|
||||
reason:
|
||||
'all three identity parts must be in the key — two scopes '
|
||||
'sharing a key is the leak itself',
|
||||
'both identity parts must be in the key — two scopes sharing a '
|
||||
'key is the leak itself',
|
||||
);
|
||||
expect(
|
||||
milkA.scoped('completed_bookings'),
|
||||
riderA.scoped('completed_bookings'),
|
||||
contains('completed_bookings'),
|
||||
);
|
||||
});
|
||||
|
||||
test('the line is no longer part of the drawer', () {
|
||||
expect(
|
||||
riderA.key,
|
||||
isNot(contains('milkMan')),
|
||||
reason: 'one rider, one day, one drawer — whatever he is carrying',
|
||||
);
|
||||
for (final legacy in riderA.legacyScopedKeys('completed_bookings')) {
|
||||
expect(legacy, isNot(riderA.scoped('completed_bookings')));
|
||||
expect(legacy, startsWith('completed_bookings::u38.t13.'));
|
||||
}
|
||||
});
|
||||
|
||||
test('the same session resolves to the same drawer', () {
|
||||
expect(
|
||||
milkA.scoped('skipped_bookings'),
|
||||
const WorkScope(
|
||||
userId: 38,
|
||||
tenantId: 13,
|
||||
line: ServiceLine.milkMan,
|
||||
).scoped('skipped_bookings'),
|
||||
riderA.scoped('skipped_bookings'),
|
||||
const WorkScope(userId: 38, tenantId: 13).scoped('skipped_bookings'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ownership', () {
|
||||
test('a milk-man record is not a logistics record', () {
|
||||
final row = milkA.stamp({'orderid': 'A1'});
|
||||
expect(milkA.owns(row), isTrue);
|
||||
expect(
|
||||
logisticsA.owns(row),
|
||||
isFalse,
|
||||
reason: 'same rider, same tenant, other LINE — must not cross',
|
||||
);
|
||||
expect(logisticsA.excludes(row), isTrue);
|
||||
test('one rider owns his record whatever he was carrying', () {
|
||||
// This asserted the opposite once: same rider, same tenant, other line
|
||||
// "must not cross". Under one-Miler that rule loses him his own work —
|
||||
// a meal drop and a parcel collection in the same shift are both his.
|
||||
final row = riderA.stamp({'orderid': 'A1'});
|
||||
expect(riderA.owns(row), isTrue);
|
||||
expect(riderA.excludes(row), isFalse);
|
||||
});
|
||||
|
||||
test('another rider cannot claim it', () {
|
||||
final row = milkA.stamp({'orderid': 'A1'});
|
||||
expect(milkB.owns(row), isFalse);
|
||||
expect(milkB.excludes(row), isTrue);
|
||||
final row = riderA.stamp({'orderid': 'A1'});
|
||||
expect(riderB.owns(row), isFalse);
|
||||
expect(riderB.excludes(row), isTrue);
|
||||
});
|
||||
|
||||
test('another tenant cannot claim it', () {
|
||||
final row = milkA.stamp({'orderid': 'A1'});
|
||||
final row = riderA.stamp({'orderid': 'A1'});
|
||||
expect(otherTenant.owns(row), isFalse);
|
||||
expect(otherTenant.excludes(row), isTrue);
|
||||
});
|
||||
@@ -94,8 +100,8 @@ void main() {
|
||||
test('the API stamps its own identity and it is honoured', () {
|
||||
// Rows straight off `/miler/assignments` carry the rider; no scope stamp
|
||||
// is involved and it must still be respected.
|
||||
expect(milkA.excludes({'orderid': 'A1', 'mileruserid': 99}), isTrue);
|
||||
expect(milkA.excludes({'orderid': 'A1', 'mileruserid': 38}), isFalse);
|
||||
expect(riderA.excludes({'orderid': 'A1', 'mileruserid': 99}), isTrue);
|
||||
expect(riderA.excludes({'orderid': 'A1', 'mileruserid': 38}), isFalse);
|
||||
});
|
||||
|
||||
test('silence is read differently by the two questions', () {
|
||||
@@ -103,8 +109,8 @@ void main() {
|
||||
// rows, where inventing ownership IS the leak), `excludes` keeps it
|
||||
// (used on rows the API just returned for this session).
|
||||
final bare = {'orderid': 'A1'};
|
||||
expect(milkA.owns(bare), isFalse);
|
||||
expect(milkA.excludes(bare), isFalse);
|
||||
expect(riderA.owns(bare), isFalse);
|
||||
expect(riderA.excludes(bare), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,34 +147,35 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('switching line cannot expose the other line’s history', () async {
|
||||
test('a mixed shift is one history, not two', () async {
|
||||
// The inverse of what this asserted before. One Miler collects meals and
|
||||
// parcels in the same shift; his Activity is his day, and a label the app
|
||||
// resolved him with must not hide half of it.
|
||||
SharedPreferences.setMockInitialValues({'userid': 38});
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
await addCompletedBookings([
|
||||
{'orderid': 'ROUND-1'},
|
||||
], terminalStatus: 'delivered');
|
||||
expect(
|
||||
(await getCompletedBookings()).map((r) => r['orderid']),
|
||||
contains('ROUND-1'),
|
||||
);
|
||||
|
||||
// Same rider, same tenant, logistics day.
|
||||
ServiceProfile.setActive(ServiceProfile.parcel);
|
||||
expect(
|
||||
(await getCompletedBookings()).where((r) => r['orderid'] == 'ROUND-1'),
|
||||
isEmpty,
|
||||
reason: 'a milk-man delivery belongs only to milk-man Activity',
|
||||
);
|
||||
|
||||
// And a logistics collection stays out of the round's history.
|
||||
await addCompletedBookings([
|
||||
{'orderid': 'PARCEL-1'},
|
||||
], terminalStatus: 'picked');
|
||||
|
||||
final ids = (await getCompletedBookings())
|
||||
.map((r) => r['orderid'])
|
||||
.toList();
|
||||
expect(
|
||||
ids,
|
||||
containsAll(<String>['ROUND-1', 'PARCEL-1']),
|
||||
reason: 'both were finished by rider 38 on tenant 13, in one shift',
|
||||
);
|
||||
|
||||
// And it still holds from the other side.
|
||||
ServiceProfile.setActive(ServiceProfile.milkMan);
|
||||
expect(
|
||||
(await getCompletedBookings()).where((r) => r['orderid'] == 'PARCEL-1'),
|
||||
isEmpty,
|
||||
reason: 'a logistics pickup belongs only to logistics Activity',
|
||||
(await getCompletedBookings()).map((r) => r['orderid']),
|
||||
containsAll(<String>['ROUND-1', 'PARCEL-1']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -179,7 +186,11 @@ void main() {
|
||||
], terminalStatus: 'delivered');
|
||||
final row = (await getCompletedBookings()).single;
|
||||
expect(row['scopeuserid'], 38);
|
||||
expect(row['scopeline'], ServiceLine.milkMan.name);
|
||||
expect(
|
||||
row.containsKey('scopeline'),
|
||||
isFalse,
|
||||
reason: 'the line is not part of ownership any more',
|
||||
);
|
||||
});
|
||||
|
||||
test('logout empties this scope', () async {
|
||||
@@ -197,6 +208,52 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('work stored under the old line-suffixed key is not lost', () async {
|
||||
// The upgrade a real rider takes mid-shift. His finished stops were
|
||||
// written to `completed_bookings::u38.t13.milkMan`; the key no longer has
|
||||
// that suffix. Dropping them would lose work he actually did, and unlike
|
||||
// a global key these rows are NOT anonymous — the key itself names him.
|
||||
final today = DateTime.now();
|
||||
final stamp =
|
||||
'${today.year}-${today.month.toString().padLeft(2, '0')}-'
|
||||
'${today.day.toString().padLeft(2, '0')}';
|
||||
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'userid': 38,
|
||||
'completed_bookings::u38.t0.milkMan': jsonEncode([
|
||||
{'orderid': 'OLD-MILK', 'completedday': stamp},
|
||||
]),
|
||||
'completed_bookings::u38.t0.parcel': jsonEncode([
|
||||
{'orderid': 'OLD-PARCEL', 'completedday': stamp},
|
||||
]),
|
||||
});
|
||||
|
||||
// The migration has to be *run*. It is not lazy — `getCompletedBookings`
|
||||
// reads the scoped key and nothing else — and this test called the reader
|
||||
// without the migrator, so it was asserting against a drawer nobody had
|
||||
// filled yet. `main()` awaits this on startup, before the first frame.
|
||||
await migrateLegacyStores();
|
||||
|
||||
final rows = await getCompletedBookings();
|
||||
expect(
|
||||
rows.map((r) => r['orderid']),
|
||||
containsAll(<String>['OLD-MILK', 'OLD-PARCEL']),
|
||||
reason: 'both old drawers belong to rider 38 — merge, never drop',
|
||||
);
|
||||
|
||||
// And the old keys are gone, so a second run cannot duplicate them.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.containsKey('completed_bookings::u38.t0.milkMan'), isFalse);
|
||||
expect(prefs.containsKey('completed_bookings::u38.t0.parcel'), isFalse);
|
||||
expect(
|
||||
(await getCompletedBookings())
|
||||
.where((r) => r['orderid'] == 'OLD-MILK')
|
||||
.length,
|
||||
1,
|
||||
reason: 'the merge must be idempotent',
|
||||
);
|
||||
});
|
||||
|
||||
test('unattributable legacy rows are dropped, not adopted', () async {
|
||||
// The pre-scoping global key, holding somebody's rows. Nothing on them
|
||||
// says whose, so adopting them would be inventing ownership.
|
||||
|
||||
Reference in New Issue
Block a user