Files
doormile_milderapp/test/state_contract_test.dart
2026-08-28 11:13:15 +05:30

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');
});
});
}