357 lines
12 KiB
Dart
357 lines
12 KiB
Dart
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/api_config.dart';
|
|
import 'package:miler/data/api_status.dart';
|
|
import 'package:miler/data/consignment_state.dart';
|
|
import 'package:miler/data/lifecycle.dart';
|
|
import 'package:miler/data/miler_api.dart';
|
|
import 'package:miler/data/mock_backend.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// THE RIDER'S RUNG AND THE OFFICE'S RUNG ARE THE SAME RUNG
|
|
///
|
|
/// ── The regression, measured against production 21 Aug 2026 ──
|
|
///
|
|
/// ```
|
|
/// BEFORE GET /miler/bookings booking 78 = Miler_Assigned
|
|
/// CALL POST /miler/bookings/78/reached
|
|
/// → 200 {"success":true,"data":{"bookingid":78,
|
|
/// "status":"Miler_Assigned"}}
|
|
/// AFTER GET /miler/bookings booking 78 = Miler_Assigned
|
|
/// ```
|
|
///
|
|
/// `reached` reports success and writes nothing. The app read only `ok`, drew
|
|
/// ARRIVED, and the hub never heard about it — so a rider stood at a kitchen
|
|
/// with a screen saying one thing and an operator watching a board that said
|
|
/// another, and neither could tell the other was wrong.
|
|
///
|
|
/// The rule these pin: **a 200 proves the call was accepted, never that the
|
|
/// state moved.** The only proof of a state change is the state in the
|
|
/// response. Every rung the app draws is now read that way, which is also what
|
|
/// makes the app correct under both of the backend's two live pickup
|
|
/// lifecycles without knowing which one is switched on.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late List<http.Request> sent;
|
|
|
|
http.Response jsonRes(Object body, [int code = 200]) => http.Response(
|
|
json.encode(body),
|
|
code,
|
|
headers: const {'content-type': 'application/json'},
|
|
);
|
|
|
|
void stub(Map<String, http.Response> routes) {
|
|
sent = <http.Request>[];
|
|
MilerApi.client = MockClient((req) async {
|
|
sent.add(req);
|
|
for (final e in routes.entries) {
|
|
if (req.url.path.endsWith(e.key)) return e.value;
|
|
}
|
|
return jsonRes({'success': true});
|
|
});
|
|
}
|
|
|
|
setUp(() async {
|
|
MockBackend.enabled = false;
|
|
SharedPreferences.setMockInitialValues({'authtoken': 't'});
|
|
await ApiConfig.setToken('t');
|
|
stub(const {});
|
|
});
|
|
|
|
tearDown(() {
|
|
MilerApi.client = http.Client();
|
|
MockBackend.enabled = kMockBackend;
|
|
});
|
|
|
|
group('reached — arrival is only arrival if the server says so', () {
|
|
test('the production no-op is reported, not believed', () async {
|
|
// The exact body production returns today.
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': true,
|
|
'data': {'bookingid': 78, 'status': 'Miler_Assigned'},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
|
|
expect(
|
|
t.isConfirmed,
|
|
isFalse,
|
|
reason:
|
|
'a 200 that leaves the booking on its old rung is not an '
|
|
'arrival, however cheerful the envelope is',
|
|
);
|
|
expect(t.isUnconfirmed, isTrue);
|
|
expect(t.bookingStatus, BookingStatus.milerAssigned);
|
|
expect(
|
|
t.evidence,
|
|
contains('Miler_Assigned'),
|
|
reason: 'the log line has to be pasteable into a backend ticket',
|
|
);
|
|
});
|
|
|
|
// ── What confirms an arrival changed, on the backend's word ──
|
|
//
|
|
// This demanded `status: "Arrived_At_Pickup"`. The backend team has since
|
|
// confirmed there is **no Arrived rung in the booking-status lifecycle**:
|
|
// it runs `pickup_scheduled → converted_to_consignment → …`, and `/reached`
|
|
// records the arrival beside it as a timestamp.
|
|
//
|
|
// So the old assertion demanded evidence of a transition the backend never
|
|
// claimed to make, and `reached` answered *unconfirmed* forever — logging a
|
|
// gap against a healthy endpoint on every arrival. The stamp is the proof;
|
|
// the status echoing `pickup_scheduled` is correct and expected.
|
|
test('the arrival stamp is what confirms it', () async {
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': true,
|
|
'data': {
|
|
'bookingid': 78,
|
|
'status': 'pickup_scheduled',
|
|
'reachedat': '2026-08-25T12:33:02+05:30',
|
|
},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(
|
|
t.evidence,
|
|
contains('2026-08-25T12:33:02+05:30'),
|
|
reason: 'the log line has to say what the proof actually was',
|
|
);
|
|
// The status is untouched and unremarkable — that is the point.
|
|
expect(t.bookingStatus, isNot(BookingStatus.unknown));
|
|
});
|
|
|
|
test('a stamped arrival confirms whatever the status says', () async {
|
|
// Including the old spelling, if a deployment still sends it: the stamp
|
|
// is the evidence either way.
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': true,
|
|
'data': {
|
|
'status': 'Miler_Assigned',
|
|
'reached_at': '2026-08-25T12:33:02Z',
|
|
},
|
|
}),
|
|
});
|
|
expect(
|
|
MilerLifecycle.reached(await MilerApi.reached(78)).isConfirmed,
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('a response with no arrival stamp is not confirmation', () async {
|
|
stub({
|
|
'/reached': jsonRes({'success': true, 'data': {}}),
|
|
});
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
expect(t.isUnconfirmed, isTrue);
|
|
expect(t.evidence, contains('no arrival stamp'));
|
|
});
|
|
|
|
test('a refusal is a refusal, with its code', () async {
|
|
stub({
|
|
'/reached': jsonRes({
|
|
'success': false,
|
|
'code': 'INVALID_STATE',
|
|
'message': 'not assigned to you',
|
|
}, 400),
|
|
});
|
|
final t = MilerLifecycle.reached(await MilerApi.reached(78));
|
|
expect(t.isRefused, isTrue);
|
|
expect(t.code, 'INVALID_STATE');
|
|
});
|
|
});
|
|
|
|
group('pickup-complete — both live lifecycles, read from the response', () {
|
|
test('the new flow stops at Collected_By_Miler', () async {
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {
|
|
'booking_status': 'Converted_To_Consignment',
|
|
'consignment_id': 46,
|
|
'consignmentstatus': 'Collected_By_Miler',
|
|
'next_action': 'start_delivery',
|
|
},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.consignmentState, ConsignmentState.collectedByMiler);
|
|
expect(t.consignmentId, '46');
|
|
expect(t.awaitsStartDelivery, isTrue);
|
|
expect(
|
|
t.isCompatibilityMode,
|
|
isFalse,
|
|
reason: 'the rider holds it — Start delivery is what makes it active',
|
|
);
|
|
// The rung the rider and the office both see: collected, not delivering.
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'),
|
|
'picked',
|
|
);
|
|
});
|
|
|
|
test('compatibility mode is detected, not assumed', () async {
|
|
// What production returns today, with the server flag off.
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {
|
|
'booking_status': 'Converted_To_Consignment',
|
|
'consignment_id': 46,
|
|
'consignmentstatus': 'Out_for_Delivery',
|
|
},
|
|
}),
|
|
});
|
|
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
|
|
expect(t.isConfirmed, isTrue);
|
|
expect(t.consignmentState, ConsignmentState.outForDelivery);
|
|
expect(
|
|
t.isCompatibilityMode,
|
|
isTrue,
|
|
reason:
|
|
'this is the fact the app must surface rather than paper over '
|
|
'— it is why the console shows Active for a fresh pickup',
|
|
);
|
|
expect(t.awaitsStartDelivery, isFalse);
|
|
// And the app must agree with the console rather than say "picked".
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
|
|
'outfordelivery',
|
|
);
|
|
});
|
|
|
|
test('no feature flag is mirrored — the same build reads both', () async {
|
|
for (final (status, expected) in const [
|
|
('Collected_By_Miler', ConsignmentState.collectedByMiler),
|
|
('Out_for_Delivery', ConsignmentState.outForDelivery),
|
|
]) {
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {'consignment_id': 46, 'consignmentstatus': status},
|
|
}),
|
|
});
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
expect(t.consignmentState, expected);
|
|
}
|
|
});
|
|
|
|
test('next_action alone is enough to place the new flow', () async {
|
|
stub({
|
|
'/pickup-complete': jsonRes({
|
|
'success': true,
|
|
'data': {'consignment_id': 46, 'next_action': 'start_delivery'},
|
|
}),
|
|
});
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
expect(t.consignmentState, ConsignmentState.collectedByMiler);
|
|
expect(t.awaitsStartDelivery, isTrue);
|
|
});
|
|
|
|
test('a pivot that minted no consignment is not confirmed', () async {
|
|
stub({
|
|
'/pickup-complete': jsonRes({'success': true, 'data': {}}),
|
|
});
|
|
final t = MilerLifecycle.pickupComplete(
|
|
await MilerApi.pickupComplete(75),
|
|
);
|
|
expect(
|
|
t.isConfirmed,
|
|
isFalse,
|
|
reason:
|
|
'without an id there is nothing to deliver against, whatever '
|
|
'the words say',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the ladder, rung by rung', () {
|
|
test('Accepted is where the rider starts', () {
|
|
expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted');
|
|
expect(BookingStatus.parse('Pickup_Scheduled').isOpen, isTrue);
|
|
expect(BookingStatus.parse('Pickup_Scheduled').isCollected, isFalse);
|
|
});
|
|
|
|
test('Arrived does not move the work boundary', () {
|
|
expect(BookingStatus.arrivedAtPickup.isCollected, isFalse);
|
|
expect(BookingStatus.arrivedAtPickup.isOpen, isTrue);
|
|
});
|
|
|
|
test('start-delivery is only for a collected consignment', () {
|
|
expect(ConsignmentState.collectedByMiler.needsRelease, isTrue);
|
|
expect(
|
|
ConsignmentState.outForDelivery.needsRelease,
|
|
isFalse,
|
|
reason:
|
|
'calling start-delivery on an already-released consignment is '
|
|
'a 4xx per press in the backend log',
|
|
);
|
|
expect(ConsignmentState.inwardedAtHub.needsRelease, isFalse);
|
|
});
|
|
|
|
test('only Out_for_Delivery is deliverable', () {
|
|
expect(ConsignmentState.outForDelivery.isDeliverable, isTrue);
|
|
expect(ConsignmentState.collectedByMiler.isDeliverable, isFalse);
|
|
expect(ConsignmentState.inwardedAtHub.isDeliverable, isFalse);
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.collectedByMiler),
|
|
DeliverGate.needsRelease,
|
|
);
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.outForDelivery),
|
|
DeliverGate.deliverable,
|
|
);
|
|
});
|
|
|
|
test('Delivered is terminal and reached only through deliver', () {
|
|
expect(ConsignmentState.delivered.isDelivered, isTrue);
|
|
expect(ConsignmentState.delivered.isDeliverable, isFalse);
|
|
expect(
|
|
ConsignmentGate.gateFor(ConsignmentState.delivered),
|
|
DeliverGate.alreadyDelivered,
|
|
);
|
|
});
|
|
|
|
test('the whole ladder maps to rider words with no enum leaking', () {
|
|
// What the rider sees at each rung. Never the API's own spelling.
|
|
expect(ApiConfig.legacyStatusFromNew('Miler_Assigned'), 'assigned');
|
|
expect(ApiConfig.legacyStatusFromNew('Pickup_Scheduled'), 'accepted');
|
|
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Collected_By_Miler'),
|
|
'picked',
|
|
);
|
|
expect(
|
|
ApiConfig.legacyStatusFromConsignment('Out_for_Delivery'),
|
|
'outfordelivery',
|
|
);
|
|
expect(ApiConfig.legacyStatusFromConsignment('Delivered'), 'delivered');
|
|
});
|
|
});
|
|
}
|