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

339 lines
13 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/assignment_lookup.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/work_domain.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/providers/pickuplog/pickuplog_provider.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE BACKEND CONTRACT, AS THIS APP READS IT
///
/// Every status `GET /miler/bookings` can carry, taken from the API reference
/// and the flow doc in the console repo rather than from what the app happened
/// to have seen. The list is the contract; the assertions are this app's
/// reading of it.
///
/// Three defects these pin:
///
/// • **`Out_for_Delivery` was not in [BookingStatus].** `pickup-complete`
/// releases a hyperlocal booking straight to it — which is every DailyGrubs
/// run — so a bag already in the rider's box parsed as `unknown`, translated
/// to the empty string, and read as *undecided*. The local collected record
/// hid it on the phone that did the pickup and nowhere else: after a restart
/// or on a second device, collected work sat on Home as pending.
/// • **An unmapped status write was reported as success.** Nothing left the
/// phone and the caller was told it had landed.
/// • **The hub's solved stop order was discarded.** `step` lives on the
/// assignment row; the app read those rows for their ids and dropped it.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// Exactly the strings the backend sends, in the backend's own casing —
/// including `Out_for_Delivery`, whose lower-case `f` differs from the
/// consignment enum's `Out_For_Delivery`.
const backendBookingStatuses = <String>[
'Created',
'Pending_Pickup',
'Miler_Assigned',
'Pickup_Scheduled',
'Picked_Up',
'Converted_To_Consignment',
'Out_for_Delivery',
'Delivered',
'Cancelled',
];
group('every status the backend sends is typed', () {
test('none of them fall through to unknown', () {
for (final raw in backendBookingStatuses) {
expect(
BookingStatus.parse(raw),
isNot(BookingStatus.unknown),
reason: '$raw is in the contract and must parse',
);
}
});
test('and each one survives the translation to the row vocabulary', () {
// `legacyStatusFromNew` is the seam between the v1 enum and the lowercase
// vocabulary every row, card and filter in this app reads. A status that
// parses but translates to something `stopStatusFromRaw` cannot read is
// the same bug one layer further on.
for (final raw in backendBookingStatuses) {
final legacy = ApiConfig.legacyStatusFromNew(raw);
expect(
stopStatusFromRaw(legacy),
isNot(StopStatus.unknown),
reason: '$raw translated to "$legacy", which the app cannot read',
);
}
});
test('both casings of out-for-delivery land on the same rung', () {
expect(
BookingStatus.parse('Out_for_Delivery'),
BookingStatus.outForDelivery,
);
expect(
BookingStatus.parse('Out_For_Delivery'),
BookingStatus.outForDelivery,
);
});
});
group('collected is a fact about the backend, not about this device', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
test(
'a released hyperlocal booking is delivery work with no local record',
() {
// The restart case: `collectedIds` is empty because this install never
// performed the pickup. The status alone has to carry it.
final stop = {
'orderid': 'A1',
'type': 'pickup',
'orderstatus': ApiConfig.legacyStatusFromNew('Out_for_Delivery'),
};
expect(WorkBoundary.pickupComplete(stop), isTrue);
expect(WorkBoundary.domainOf(stop), WorkDomain.delivery);
},
);
test('and a delivered one is finished, not pending', () {
final stop = {
'orderid': 'A1',
'type': 'pickup',
'orderstatus': ApiConfig.legacyStatusFromNew('Delivered'),
};
expect(WorkBoundary.domainOf(stop), WorkDomain.closed);
});
test('an assignment the rider has not acted on is still Home\'s', () {
for (final raw in ['Created', 'Pending_Pickup', 'Miler_Assigned']) {
final stop = {
'orderid': 'A1',
'orderstatus': ApiConfig.legacyStatusFromNew(raw),
};
expect(WorkBoundary.domainOf(stop), WorkDomain.pickup, reason: raw);
}
});
});
group('the rungs come in order', () {
setUp(() => ServiceProfile.setActive(ServiceProfile.milkMan));
test('accepting does not skip the arrival rung', () {
// The reported bug: accept a stop, select it again, and the bar offered
// **Mark as Picked** for a kitchen the rider had not reached.
//
// `Pickup_Scheduled` is what the backend writes on accept, and it was
// translated to `active` — which `stopStateOf` reads as *the rider is on
// the stop*, ahead of the local accepted record. So the queue's own
// answer promoted every accepted stop straight past arrival, and
// `reached` — the one rung with a real endpoint behind it — could never
// be sent.
final stop = {
'orderid': 'A1',
'orderstatus': ApiConfig.legacyStatusFromNew('Pickup_Scheduled'),
};
expect(
stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}),
StopState.accepted,
reason: 'accepted, not on the stop',
);
});
test('and arriving is its own rung, not "active"', () {
// ── The two used to be one, and it cost the rider his arrival ──
//
// `stopStateOf` folded `arrived` into [StopState.active], so the Home row
// had no arrived to render and the live chip printed the literal word
// **Active** the moment he slid *confirm arrival*. The rung had moved,
// `/reached` had fired and the local record was written — and the one
// word on screen said something else, which is indistinguishable from
// the arrival not registering.
//
// They are different facts: arrived is a rung on the pickup ladder, and
// active is the backend's word for a delivery under way.
final stop = {'orderid': 'A1', 'orderstatus': 'arrived'};
expect(
stopStateOf(stop, acceptedIds: const {}, rejectedIds: const {}),
StopState.arrived,
);
expect(
stopStateOf(
{'orderid': 'A1', 'orderstatus': 'active'},
acceptedIds: const {},
rejectedIds: const {},
),
StopState.active,
reason: 'and active still means active',
);
});
test('the local accept record reads the same rung as the server\'s', () {
// Optimistic accept, before the queue has caught up: both paths must
// land on the same rung or the bar changes its offer under him.
final stop = {'orderid': 'A1', 'orderstatus': 'assigned'};
expect(
stopStateOf(stop, acceptedIds: const {'A1'}, rejectedIds: const {}),
StopState.accepted,
);
});
});
group('an unknown status is never a settled one', () {
test('a future booking status reads as undecided, not as finished', () {
const future = 'Rescheduled_By_Hub';
expect(BookingStatus.parse(future), BookingStatus.unknown);
expect(BookingStatus.parse(future).isCollected, isFalse);
final stop = {
'orderid': 'A1',
'orderstatus': ApiConfig.legacyStatusFromNew(future),
};
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(
WorkBoundary.domainOf(stop),
WorkDomain.pickup,
reason: 'wrong-but-safe: it stays where the rider can still act on it',
);
expect(WorkBoundary.isClosed(stop), isFalse);
});
test('a future assignment status is not settled either', () {
expect(AssignmentStatus.parse('Escalated'), AssignmentStatus.unknown);
expect(AssignmentStatus.unknown.isSettled, isFalse);
expect(AssignmentStatus.unknown.isLive, isFalse);
expect(AssignmentStatus.unknown.needsDecision, isFalse);
});
test('a future consignment status is not delivered', () {
expect(
ConsignmentStatus.parse('Held_At_Customs'),
ConsignmentStatus.unknown,
);
expect(ConsignmentStatus.unknown.isDelivered, isFalse);
});
});
group('a write this app cannot map is a failure, not a success', () {
test('the unmapped branch reports failure and sends nothing', () async {
// No network stub: reaching one would itself be the bug. The branch must
// refuse before any request is built.
final res = await UpdatePickupProvider().updatePickup({
'pickupid': 1,
'orderstatus': 'teleported',
});
expect(res, isNotNull);
expect(res!['status'], isFalse);
expect(res['message'], contains('teleported'));
});
});
group('the consignment id survives the pivot', () {
setUp(() => SharedPreferences.setMockInitialValues({}));
test('it is recorded at pickup-complete and read back at the door', () async {
// The reported failure: press **Delivered** at a customer's door and the
// app answers "could not update — check your connection", with no request
// ever made.
//
// `deliver` and `skip` key on the CONSIGNMENT, which only exists after
// `pickup-complete` creates it. That response was wrapped and discarded,
// and the row the rider works from on Deliveries is a snapshot taken
// *before* the conversion — so nothing on the device knew what to
// deliver, and the refusal came from the app, not the network.
await rememberConsignmentId('A1', 'CN-9001');
expect((await getConsignmentIds())['A1'], 'CN-9001');
});
test('and is dropped once the stop is finished', () async {
await rememberConsignmentId('A1', 'CN-9001');
await forgetConsignmentIds(['A1']);
expect((await getConsignmentIds()).containsKey('A1'), isFalse);
});
test('an empty id is not worth recording', () async {
await rememberConsignmentId('A1', '');
await rememberConsignmentId('', 'CN-9001');
expect(await getConsignmentIds(), isEmpty);
});
});
group('the hub\'s solved stop order is kept', () {
test('step is read from the assignment row', () {
final index = AssignmentLookup.buildStepIndex([
{
'bookingid': 118,
'bookingassignmentid': 1,
'step': 2,
'assignmentstatus': 'Accepted',
},
{
'bookingid': 119,
'bookingassignmentid': 2,
'step': 1,
'assignmentstatus': 'Assigned',
},
]);
expect(index['118'], 2);
expect(index['119'], 1);
});
test('step 0 means unsequenced and is not stored as a position', () {
// `step: 0` sorts to the end in `Trip.sortStops`; storing it would make an
// unsequenced stop look like it had been solved into first place.
final index = AssignmentLookup.buildStepIndex([
{
'bookingid': 120,
'bookingassignmentid': 3,
'step': 0,
'assignmentstatus': 'Assigned',
},
]);
expect(index.containsKey('120'), isFalse);
});
test('a reassigned booking takes the live assignment\'s sequence', () {
// Same rule as the id index: newest first, but an actionable row beats a
// newer terminal one, so the two indexes cannot describe different rows.
final rows = [
{
'bookingid': 118,
'bookingassignmentid': 9,
'step': 5,
'assignmentstatus': 'Rejected',
},
{
'bookingid': 118,
'bookingassignmentid': 10,
'step': 3,
'assignmentstatus': 'Accepted',
},
];
expect(AssignmentLookup.buildStepIndex(rows)['118'], 3);
expect(AssignmentLookup.buildIndex(rows)['118'], 10);
});
});
}