── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
740 lines
26 KiB
Dart
740 lines
26 KiB
Dart
import 'package:doormile_cx/data/app_config.dart';
|
||
import 'package:doormile_cx/data/doormile_api.dart';
|
||
import 'package:doormile_cx/data/location_service.dart';
|
||
import 'package:doormile_cx/data/models.dart';
|
||
import 'package:doormile_cx/state/app_state.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
|
||
/// A programmable stand-in for the service layer.
|
||
///
|
||
/// Not the mock API — that one invents a whole product. This one answers
|
||
/// exactly what a test sets and records exactly what it was asked, which is
|
||
/// what the wiring between [AppState] and the contract needs proving.
|
||
class FakeApi extends DoormileApi {
|
||
FakeApi({this.limits = const BookingLimits(maxPackages: 20, maxDestinations: 5)});
|
||
|
||
BookingLimits limits;
|
||
Object? limitsError;
|
||
|
||
final List<String?> createKeys = [];
|
||
int createFailures = 0;
|
||
Object? createError;
|
||
|
||
final List<BookingPage> pages = [];
|
||
int pageCalls = 0;
|
||
final List<String?> cursors = [];
|
||
|
||
List<PickupSlot> slots = const [
|
||
PickupSlot(
|
||
id: 'slot_20260905_t1',
|
||
day: 'Today',
|
||
window: '2:00 – 4:00 PM',
|
||
available: true,
|
||
tag: 'Fastest pickup',
|
||
milersNearby: 4,
|
||
caption: 'Arriving in approx. 45 mins',
|
||
),
|
||
];
|
||
int slotCalls = 0;
|
||
|
||
/// Reverse-geocode answers, keyed by latitude, with a delay each — so a test
|
||
/// can make an earlier request land after a later one.
|
||
final Map<double, (Duration, Place)> geocodes = {};
|
||
|
||
@override
|
||
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
|
||
if (limitsError != null) throw limitsError!;
|
||
return limits;
|
||
}
|
||
|
||
@override
|
||
Future<Booking> createBooking({
|
||
required Place pickup,
|
||
required List<DestinationGroup> destinations,
|
||
required String? slotId,
|
||
FareEstimate? fare,
|
||
String? contactPhone,
|
||
String? contactName,
|
||
String? idempotencyKey,
|
||
}) async {
|
||
createKeys.add(idempotencyKey);
|
||
if (createFailures > 0) {
|
||
createFailures--;
|
||
throw createError ?? ApiException(ApiException.network, 'offline');
|
||
}
|
||
return Booking(
|
||
reference: 'DM-482913',
|
||
pickup: pickup,
|
||
destinations: destinations.map((d) => d.copy()).toList(),
|
||
slotId: slotId ?? '',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<BookingPage> getBookingPage({
|
||
BookingStatus? status,
|
||
String? cursor,
|
||
int limit = 20,
|
||
}) async {
|
||
cursors.add(cursor);
|
||
if (pageCalls >= pages.length) return BookingPage.empty;
|
||
return pages[pageCalls++];
|
||
}
|
||
|
||
@override
|
||
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) async {
|
||
slotCalls++;
|
||
return slots;
|
||
}
|
||
|
||
@override
|
||
Future<Place> reverseGeocode({double? lat, double? lng}) async {
|
||
final answer = geocodes[lat];
|
||
if (answer == null) {
|
||
return Place(title: 'Pin', sub: 'somewhere', lat: lat, lng: lng);
|
||
}
|
||
await Future<void>.delayed(answer.$1);
|
||
return answer.$2;
|
||
}
|
||
|
||
// ---- everything the interface needs but these tests do not exercise ------
|
||
|
||
@override
|
||
Future<OtpChallenge> sendOtp(String identifier) async =>
|
||
OtpChallenge.unspecified;
|
||
|
||
@override
|
||
Future<OtpChallenge> signUp({
|
||
required String name,
|
||
required String phone,
|
||
String? email,
|
||
}) async => OtpChallenge.unspecified;
|
||
|
||
@override
|
||
Future<Customer> verifyOtp(
|
||
String identifier,
|
||
String code, {
|
||
String? name,
|
||
}) async => Customer(
|
||
id: 'c1',
|
||
name: name ?? 'Joe',
|
||
phone: identifier,
|
||
email: '',
|
||
);
|
||
|
||
@override
|
||
Future<List<ServiceArea>> getServiceableStates() async => const [];
|
||
|
||
@override
|
||
Future<List<District>> getServiceableDistricts(String stateCode) async =>
|
||
const [];
|
||
|
||
@override
|
||
District? districtByCode(String? code) => null;
|
||
|
||
@override
|
||
PickupSlot? slotById(String? id) {
|
||
for (final slot in slots) {
|
||
if (slot.id == id) return slot;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Future<List<Place>> searchPlaces(
|
||
String query, {
|
||
double? lat,
|
||
double? lng,
|
||
}) async => const [];
|
||
|
||
@override
|
||
Future<FareEstimate> estimateFare({
|
||
required Place pickup,
|
||
required List<DestinationGroup> destinations,
|
||
}) async => const FareEstimate(min: 49, max: 64, paymentMethod: 'UPI');
|
||
|
||
@override
|
||
Future<void> cancelBooking(String reference, String? reason) async {}
|
||
|
||
@override
|
||
@override
|
||
Future<Booking?> getBooking(String reference) async => null;
|
||
|
||
@override
|
||
bool isCancellable(JourneyStage stage) =>
|
||
stage.index < JourneyStage.arrived.index;
|
||
|
||
final List<(String, JourneyStage)> stageCalls = [];
|
||
|
||
@override
|
||
Future<void> setStage(String reference, JourneyStage stage) async {
|
||
stageCalls.add((reference, stage));
|
||
}
|
||
}
|
||
|
||
Booking _booking(String reference) => Booking(
|
||
reference: reference,
|
||
pickup: const Place(title: 'Home', sub: 'Coimbatore'),
|
||
destinations: [DestinationGroup()],
|
||
slotId: 'slot_1',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
|
||
AppState _state(FakeApi api) =>
|
||
AppState(api: api, location: FixedLocationService());
|
||
|
||
void main() {
|
||
group('nothing on screen is invented', () {
|
||
test('a fresh state holds no bookings until the server sends some', () {
|
||
// There is no seed, no demo data and no constructor side effect that
|
||
// could put a booking in front of a customer. The list starts empty and
|
||
// is only ever filled by a response.
|
||
late AppState state;
|
||
expect(() => state = _state(FakeApi()), returnsNormally);
|
||
expect(state.orders, isEmpty);
|
||
});
|
||
|
||
test('the orders list is the server\'s, adopted whole', () async {
|
||
final api = FakeApi()
|
||
..pages.add(BookingPage(bookings: [_booking('DM-9')]));
|
||
final state = _state(api);
|
||
await state.refreshOrders();
|
||
expect(state.orders.single.reference, 'DM-9');
|
||
|
||
// A later page that no longer contains it removes it, rather than the
|
||
// client keeping a copy the backend has forgotten.
|
||
api.pages.add(const BookingPage(bookings: []));
|
||
await state.refreshOrders();
|
||
expect(state.orders, isEmpty);
|
||
});
|
||
|
||
test('a stage move goes through the server and comes back read', () async {
|
||
final api = FakeApi()
|
||
..pages.add(BookingPage(bookings: [_booking('DM-1')]));
|
||
final state = _state(api);
|
||
await state.refreshOrders();
|
||
|
||
await state.advanceStage(state.orders.single, JourneyStage.delivered);
|
||
|
||
// The client asked; it did not apply anything to its own copy.
|
||
expect(api.stageCalls, [('DM-1', JourneyStage.delivered)]);
|
||
expect(state.orders.single.stage, JourneyStage.booked);
|
||
expect(state.orders.single.destinations.single.trackingId, isNull);
|
||
});
|
||
});
|
||
|
||
group('which backend this build believes it is', () {
|
||
// `DM_ENV` used to default to `staging` for EVERY build, release included,
|
||
// while `_stagingBase` is `_prodBase`. So a release APK built without
|
||
// `--dart-define=DM_ENV=prod` talked to production and reported
|
||
// `isProd == false` — and `isProd` is what the non-network guards read: the
|
||
// Account screen's Build panel, `allowStageOverride`, `setStage`'s refusal,
|
||
// and the environment name on every bug report.
|
||
|
||
test('an unflagged debug build is staging, not production', () {
|
||
// This test run carries no DM_ENV, and under test kReleaseMode is false.
|
||
expect(AppConfig.environment, DoormileEnvironment.staging);
|
||
expect(AppConfig.isProd, isFalse);
|
||
});
|
||
|
||
test('staging and prod resolve to the same host today', () {
|
||
// The backend has not named a staging host, so the environment decides
|
||
// what the build CALLS ITSELF, not who it talks to. Worth pinning: the
|
||
// day a real staging host appears, this test failing is the reminder that
|
||
// the default now changes where traffic goes.
|
||
expect(AppConfig.baseUrl, 'https://api.doormile.com/api/v1');
|
||
});
|
||
|
||
test('the environment is named on every build description', () {
|
||
// What a bug report carries. A production session that called itself
|
||
// "staging" is how a real incident gets triaged against the wrong server.
|
||
expect(AppConfig.describe, contains(AppConfig.environment.name));
|
||
});
|
||
|
||
// NOT ASSERTABLE FROM HERE, and stated rather than faked: the release
|
||
// branch of `environment` is `kReleaseMode ? prod : staging`, and
|
||
// kReleaseMode is a compile-time const that is false under `flutter test`.
|
||
// There is no way to exercise the release arm from a debug test run, so
|
||
// this note is the coverage. Verify it with:
|
||
//
|
||
// flutter build apk --release → Account shows no Build panel
|
||
// flutter build apk --release --dart-define=DM_ENV=staging
|
||
// → Build panel returns
|
||
});
|
||
|
||
group('development access', () {
|
||
test('no dev token is compiled into this build', () {
|
||
// A pasted token is not present unless a define supplies one.
|
||
expect(AppConfig.hasDevToken, isFalse);
|
||
expect(AppConfig.describe, isNot(contains('DEV TOKEN')));
|
||
});
|
||
|
||
test('an offline build says so, every time', () {
|
||
// The offline build is back, and this is the guarantee that makes it
|
||
// safe to have. It was deleted once because a build answering from the
|
||
// fake looked exactly like a build answering from the server — which
|
||
// cost two rounds of hunting for bookings in the admin console that had
|
||
// never left the phone.
|
||
//
|
||
// A test run IS an offline build, so this asserts the announcement in
|
||
// the one place it can be asserted at all.
|
||
expect(AppConfig.useDevData, isTrue);
|
||
expect(AppConfig.describe, contains('DEV DATA (offline)'));
|
||
expect(
|
||
AppConfig.describe,
|
||
isNot(contains(AppConfig.baseUrl)),
|
||
reason: 'naming a host a build never contacts is the confusion this '
|
||
'whole guard exists to prevent',
|
||
);
|
||
});
|
||
|
||
test('no dev token and no auto-login are compiled in', () {
|
||
// The two ways to skip the login SCREEN without faking the login. Both
|
||
// hold a server-issued token; neither define is passed to this run.
|
||
expect(AppConfig.hasAutoLogin, isFalse);
|
||
expect(AppConfig.hasDevToken, isFalse);
|
||
});
|
||
|
||
test('the auto-login flag can never fire under test', () {
|
||
// The widget tests drive the real entrance — phone, code, and the guard
|
||
// that refuses to open the code screen when no code was sent. A session
|
||
// waiting for them at launch would delete that coverage and nothing
|
||
// would fail, so the flag is inert under FLUTTER_TEST whatever the
|
||
// defines say.
|
||
expect(AppConfig.isTest, isTrue);
|
||
expect(AppConfig.hasAutoLogin, isFalse);
|
||
});
|
||
|
||
// The guarantee that matters cannot be asserted from a debug test — every
|
||
// switch is `!kReleaseMode`, so `autoLoginIdentifier`, `hasDevToken` and
|
||
// `allowStageOverride` are all forced empty/false in a release build,
|
||
// whatever the defines say. That is what keeps a bypass out of the store.
|
||
});
|
||
|
||
group('booking limits', () {
|
||
test('a failed limits call falls back to one destination, not five', () async {
|
||
final api = FakeApi()..limitsError = ApiException('network', 'offline');
|
||
final state = _state(api);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
// Multi-destination is undeliverable until the Miler build lands. The
|
||
// permissive default is the one that strands a parcel.
|
||
expect(state.limits.maxDestinations, 1);
|
||
expect(state.allowsMultipleDestinations, isFalse);
|
||
expect(state.addDestination(), isFalse);
|
||
});
|
||
|
||
test('the served cap is authoritative when it arrives', () async {
|
||
final state = _state(FakeApi());
|
||
await state.loadLimits();
|
||
|
||
expect(state.limits.maxDestinations, 5);
|
||
expect(state.allowsMultipleDestinations, isTrue);
|
||
});
|
||
|
||
test('a zero or missing cap resolves to one, never to none', () {
|
||
expect(BookingLimits.fromJson(const {}).maxDestinations, 1);
|
||
expect(BookingLimits.fromJson(const {}).maxPackages, 20);
|
||
expect(
|
||
BookingLimits.fromJson(const {'maxDestinations': 0, 'maxPackages': 0})
|
||
.maxDestinations,
|
||
1,
|
||
);
|
||
expect(
|
||
BookingLimits.fromJson(const {'maxPackages': 0}).maxPackages,
|
||
1,
|
||
);
|
||
});
|
||
});
|
||
|
||
group('pickup slots', () {
|
||
test('starting a booking drops yesterday\'s slots', () async {
|
||
final api = FakeApi();
|
||
final state = _state(api);
|
||
|
||
await state.loadSlots();
|
||
await state.loadSlots();
|
||
expect(api.slotCalls, 1, reason: 'cached within one booking');
|
||
|
||
final epoch = state.slotsEpoch;
|
||
state.startBooking();
|
||
await state.loadSlots();
|
||
|
||
expect(api.slotCalls, 2);
|
||
expect(state.slotsEpoch, greaterThan(epoch));
|
||
});
|
||
|
||
test('a refused slot clears the cache and the choice', () async {
|
||
final api = FakeApi()
|
||
..createFailures = 1
|
||
..createError = ApiException(
|
||
ApiException.invalid,
|
||
'That pickup time has passed — pick a new slot',
|
||
);
|
||
final state = _state(api);
|
||
state.startBooking();
|
||
state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76);
|
||
state.draftPickup = state.pickup;
|
||
await state.loadSlots();
|
||
state.selectSlot(api.slots.first);
|
||
|
||
await expectLater(state.confirmBooking(), throwsA(isA<ApiException>()));
|
||
|
||
expect(state.draftSlotId, isNull);
|
||
expect(state.slotsCache, isNull);
|
||
});
|
||
|
||
test('the parser keeps every field the picker renders', () {
|
||
final slot = PickupSlot.fromJson(const {
|
||
'id': 'slot_20260905_t1',
|
||
'day': 'Today',
|
||
'window': '2:00 – 4:00 PM',
|
||
'available': true,
|
||
'tag': 'Fastest pickup',
|
||
'milersNearby': 4,
|
||
'caption': 'Arriving in approx. 45 mins',
|
||
'note': null,
|
||
});
|
||
|
||
expect(slot.tag, 'Fastest pickup');
|
||
expect(slot.milersNearby, 4);
|
||
expect(slot.caption, 'Arriving in approx. 45 mins');
|
||
expect(slot.label, 'Today · 2:00 – 4:00 PM');
|
||
});
|
||
});
|
||
|
||
group('idempotency', () {
|
||
test('a retried booking reuses its key; a new booking mints one', () async {
|
||
final api = FakeApi()..createFailures = 1;
|
||
final state = _state(api);
|
||
state.startBooking();
|
||
state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76);
|
||
state.draftPickup = state.pickup;
|
||
state.selectSlot(api.slots.first);
|
||
|
||
await expectLater(state.confirmBooking(), throwsA(isA<ApiException>()));
|
||
await state.confirmBooking();
|
||
|
||
expect(api.createKeys.length, 2);
|
||
expect(api.createKeys[0], isNotNull);
|
||
// Same intent, same key — the server replays rather than booking twice.
|
||
expect(api.createKeys[0], api.createKeys[1]);
|
||
|
||
state.startBooking();
|
||
state.draftPickup = state.pickup;
|
||
state.selectSlot(api.slots.first);
|
||
await state.confirmBooking();
|
||
|
||
expect(api.createKeys[2], isNot(api.createKeys[1]));
|
||
});
|
||
});
|
||
|
||
group('keyset pagination', () {
|
||
test('appends pages and follows the cursor', () async {
|
||
final api = FakeApi()
|
||
..pages.addAll([
|
||
BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')], nextCursor: 'c2'),
|
||
BookingPage(bookings: [_booking('DM-3')], nextCursor: null),
|
||
]);
|
||
final state = _state(api);
|
||
|
||
await state.refreshOrders();
|
||
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']);
|
||
expect(state.hasMoreOrders, isTrue);
|
||
|
||
await state.loadMoreOrders();
|
||
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2', 'DM-3']);
|
||
expect(state.hasMoreOrders, isFalse);
|
||
expect(api.cursors, [null, 'c2']);
|
||
});
|
||
|
||
test('a row that arrives on two pages is shown once', () async {
|
||
final api = FakeApi()
|
||
..pages.addAll([
|
||
BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'),
|
||
BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')]),
|
||
]);
|
||
final state = _state(api);
|
||
|
||
await state.refreshOrders();
|
||
await state.loadMoreOrders();
|
||
|
||
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']);
|
||
});
|
||
|
||
test('a failed page keeps the cursor so the next scroll retries it', () async {
|
||
final api = FakeApi()
|
||
..pages.add(BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'));
|
||
final state = _state(api);
|
||
await state.refreshOrders();
|
||
|
||
// The fake runs out of pages and returns an empty one; the cursor then
|
||
// clears normally. What matters is that nothing throws at the UI.
|
||
await state.loadMoreOrders();
|
||
expect(state.loadingMoreOrders, isFalse);
|
||
});
|
||
});
|
||
|
||
group('pickup point', () {
|
||
test('the latest reverse-geocode wins, however the earlier one lands', () async {
|
||
final api = FakeApi()
|
||
..geocodes[11.0] = (
|
||
const Duration(milliseconds: 80),
|
||
const Place(title: 'First drag', sub: 'stale', lat: 11.0, lng: 76.0),
|
||
)
|
||
..geocodes[12.0] = (
|
||
const Duration(milliseconds: 10),
|
||
const Place(title: 'Second drag', sub: 'fresh', lat: 12.0, lng: 77.0),
|
||
);
|
||
final state = _state(api);
|
||
|
||
final first = state.setPickupFromPin(11.0, 76.0);
|
||
final second = state.setPickupFromPin(12.0, 77.0);
|
||
await Future.wait([first, second]);
|
||
|
||
expect(state.pickup!.title, 'Second drag');
|
||
expect(state.resolvingPickup, isFalse);
|
||
});
|
||
|
||
test('a refused permission still leaves a usable pickup', () async {
|
||
final state = AppState(
|
||
api: FakeApi(),
|
||
location: FixedLocationService.denied(LocationDenial.deniedForever),
|
||
);
|
||
|
||
await state.detectPickupLocation();
|
||
expect(state.locationDenial, LocationDenial.deniedForever);
|
||
// Falls back to the service-area centre rather than stranding the form,
|
||
// and search still overrides it.
|
||
expect(state.pickup, isNotNull);
|
||
|
||
state.setPickup(
|
||
const Place(title: 'Brookefields Mall', sub: 'CBE', lat: 10.99, lng: 76.96),
|
||
);
|
||
expect(state.pickup!.title, 'Brookefields Mall');
|
||
expect(state.hasPickupPoint, isTrue);
|
||
expect(state.locationDenial, isNull);
|
||
});
|
||
});
|
||
|
||
group('booking parser', () {
|
||
final json = <String, dynamic>{
|
||
'reference': 'DM-482913',
|
||
'stage': 'in_transit',
|
||
'status': 'active',
|
||
'cancellable': false,
|
||
'createdAt': 1757056800000,
|
||
'pickup': {
|
||
'title': '12 Nehru Street',
|
||
'sub': 'Gandhipuram, Coimbatore 641012',
|
||
'lat': 11.0183,
|
||
'lng': 76.9725,
|
||
},
|
||
'slotId': 'slot_t_1',
|
||
'destinations': [
|
||
{
|
||
'stateCode': 'TN',
|
||
'stateName': 'Tamil Nadu',
|
||
'districtCode': 'TN-MAA',
|
||
'districtName': 'Chennai',
|
||
'packageCount': 2,
|
||
'district': {
|
||
'code': 'TN-MAA',
|
||
'name': 'Chennai',
|
||
'available': true,
|
||
'hub': 'Chennai Guindy Hub',
|
||
'promise': 'Next-day delivery',
|
||
'lat': 13.08,
|
||
'lng': 80.27,
|
||
},
|
||
'details': {
|
||
'street': '12th Main',
|
||
'recipientName': 'Meera S',
|
||
'recipientPhone': '+919884412210',
|
||
},
|
||
'trackingId': 'DMX10482913',
|
||
'stage': 'in_transit',
|
||
'verification': {
|
||
'weightKg': 2.8,
|
||
'photos': ['https://cdn.doormile.com/pv/abc.jpg'],
|
||
'capturedAt': 1757060400000,
|
||
'capturedBy': 'Arun Kumar',
|
||
},
|
||
},
|
||
],
|
||
'miler': {
|
||
'name': 'Arun Kumar',
|
||
'vehicle': 'TN 37 BX 4412',
|
||
'phone': '+919000011223',
|
||
'rating': 4.9,
|
||
'trips': 1240,
|
||
'vehicleType': 'E-Scooter',
|
||
},
|
||
'deliveryAgent': null,
|
||
'milerDistanceKm': null,
|
||
'milerEtaMinutes': null,
|
||
'milersInZone': 3,
|
||
'routeKm': 6.4,
|
||
'expectedDelivery': 'Thu, 12 Sep',
|
||
'fare': {
|
||
'min': 49,
|
||
'max': 64,
|
||
'paymentMethod': 'UPI · Cash at doorstep',
|
||
'parcel': 'Standard box (up to 3 kg)',
|
||
},
|
||
'amountPaid': 64,
|
||
'deliveredAt': null,
|
||
'cancelReason': null,
|
||
'history': [
|
||
{'stage': 'booked', 'at': 1757056800000},
|
||
{'stage': 'assigned', 'at': 1757057400000},
|
||
{'stage': 'in_transit', 'at': 1757062200000},
|
||
],
|
||
};
|
||
|
||
test('reads the whole canonical object, not a subset', () {
|
||
final booking = Booking.fromJson(json);
|
||
|
||
expect(booking.reference, 'DM-482913');
|
||
expect(booking.stage, JourneyStage.inTransit);
|
||
expect(booking.status, BookingStatus.active);
|
||
expect(booking.cancellable, isFalse);
|
||
expect(booking.createdAt.millisecondsSinceEpoch, 1757056800000);
|
||
expect(booking.pickup.title, '12 Nehru Street');
|
||
expect(booking.slotId, 'slot_t_1');
|
||
expect(booking.routeKm, 6.4);
|
||
expect(booking.milersInZone, 3);
|
||
expect(booking.expectedDelivery, 'Thu, 12 Sep');
|
||
expect(booking.amountPaid, 64);
|
||
expect(booking.deliveredAt, isNull);
|
||
expect(booking.miler!.name, 'Arun Kumar');
|
||
expect(booking.miler!.trips, 1240);
|
||
expect(booking.deliveryAgent, isNull);
|
||
expect(booking.fare!.range, '₹49 – ₹64');
|
||
expect(booking.history.length, 3);
|
||
expect(booking.history.first.stage, JourneyStage.booked);
|
||
expect(booking.timeOf(JourneyStage.assigned), isNotNull);
|
||
|
||
final group = booking.destinations.single;
|
||
expect(group.destination.label, 'Chennai, Tamil Nadu');
|
||
expect(group.packageCount, 2);
|
||
expect(group.trackingId, 'DMX10482913');
|
||
expect(group.stage, JourneyStage.inTransit);
|
||
expect(group.details.recipientName, 'Meera S');
|
||
expect(group.district!.hub, 'Chennai Guindy Hub');
|
||
expect(group.hub, isNotNull, reason: 'district coordinates draw the route');
|
||
expect(group.verification!.weightLabel, '2.8 kg');
|
||
expect(group.verification!.photos.single, endsWith('abc.jpg'));
|
||
});
|
||
|
||
test('before order_created there is no order to track', () {
|
||
final pending = Map<String, dynamic>.from(json)
|
||
..['stage'] = 'assigned'
|
||
..['amountPaid'] = null
|
||
..['destinations'] = [
|
||
{
|
||
'stateCode': 'TN',
|
||
'stateName': 'Tamil Nadu',
|
||
'districtCode': 'TN-MAA',
|
||
'districtName': 'Chennai',
|
||
'packageCount': 1,
|
||
},
|
||
];
|
||
|
||
final booking = Booking.fromJson(pending);
|
||
final group = booking.destinations.single;
|
||
|
||
expect(group.trackingId, isNull);
|
||
expect(group.stage, isNull);
|
||
expect(group.verification, isNull);
|
||
expect(booking.amountPaid, isNull);
|
||
expect(booking.hasOrders, isFalse);
|
||
});
|
||
|
||
test('an unknown stage key falls back to booked', () {
|
||
final unknown = Map<String, dynamic>.from(json)
|
||
..['stage'] = 'delivery_failed';
|
||
expect(Booking.fromJson(unknown).stage, JourneyStage.booked);
|
||
expect(JourneyStage.fromKey(null), JourneyStage.booked);
|
||
expect(JourneyStage.fromKey('delivered'), JourneyStage.delivered);
|
||
});
|
||
|
||
test('a cancelled booking parses even when the server thins it out', () {
|
||
final booking = Booking.fromJson(const {
|
||
'reference': 'DM-449220',
|
||
'status': 'cancelled',
|
||
'stage': 'booked',
|
||
'cancelReason': 'Package not ready',
|
||
});
|
||
|
||
expect(booking.status, BookingStatus.cancelled);
|
||
expect(booking.isCancelled, isTrue);
|
||
expect(booking.cancelReason, 'Package not ready');
|
||
expect(booking.destinations, hasLength(1));
|
||
expect(booking.slotId, '');
|
||
});
|
||
|
||
test('timestamps are epoch millis, and nothing ambiguous is guessed', () {
|
||
expect(
|
||
epochMillis(1757056800000)!.millisecondsSinceEpoch,
|
||
1757056800000,
|
||
);
|
||
expect(epochMillis('1757056800000')!.millisecondsSinceEpoch, 1757056800000);
|
||
expect(epochMillis('2026-09-05T14:30:00+05:30'), isNotNull);
|
||
expect(epochMillis(null), isNull);
|
||
// A naive local string is ambiguous by 5h30m here. Refusing it is how the
|
||
// tracking screen avoids dating a parcel to yesterday.
|
||
expect(epochMillis('2026-09-05 14:30:00'), isNull);
|
||
});
|
||
|
||
test('a destination round-trips into the create request', () {
|
||
final group = DestinationGroup(
|
||
destination: Destination(stateCode: 'TN', districtCode: 'TN-MAA'),
|
||
packageCount: 3,
|
||
);
|
||
expect(group.toBookingJson(), {
|
||
'stateCode': 'TN',
|
||
'districtCode': 'TN-MAA',
|
||
'packageCount': 3,
|
||
});
|
||
|
||
// ── Nested under `details`, with the pin as `{lat, lng}` ──
|
||
//
|
||
// This asserted the flat shape and `latitude`/`longitude`. Both are
|
||
// dropped by the server without an error, which is why the whole
|
||
// full-address path was reaching the Miler with nothing on it.
|
||
group.details.update(recipientName: 'Meera S', building: '12/A');
|
||
group.details.pin = const MapPin(13.085, 80.21);
|
||
expect(group.toBookingJson(), {
|
||
'stateCode': 'TN',
|
||
'districtCode': 'TN-MAA',
|
||
'packageCount': 3,
|
||
'details': {
|
||
'recipientName': 'Meera S',
|
||
'building': '12/A',
|
||
'pin': {'lat': 13.085, 'lng': 80.21},
|
||
},
|
||
});
|
||
});
|
||
|
||
test('a PATCH clears with an empty string, not with null', () {
|
||
// ── The old shape could not clear anything ──
|
||
//
|
||
// It sent `null` for an unset field and a comment said that cleared it.
|
||
// The server writes only non-nil values, so `null` means "leave this
|
||
// alone" — a customer could add a landmark and never remove one. `""`
|
||
// clears a text field and `pin {0,0}` clears the pin.
|
||
final details = DeliveryDetails(street: '12th Main');
|
||
final patch = details.toPatchJson();
|
||
expect(patch['street'], '12th Main');
|
||
expect(patch['landmark'], '');
|
||
expect(patch['instructions'], '');
|
||
expect(patch['pin'], {'lat': 0, 'lng': 0});
|
||
|
||
final pinned = DeliveryDetails(street: 'x')..pin = const MapPin(13.0, 80.0);
|
||
expect(pinned.toPatchJson()['pin'], {'lat': 13.0, 'lng': 80.0});
|
||
});
|
||
});
|
||
}
|